From a10f01fc86e2c8cec2b605eb4ff7e51e09c0479b Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sun, 17 Aug 2025 15:54:54 +0000 Subject: [PATCH 01/18] windows support added --- .../example/android/app/build.gradle.kts | 44 +++++++++++++++++++ .../auth0_flutter_example/MainActivity.kt | 5 +++ .../example/android/build.gradle.kts | 21 +++++++++ .../example/android/settings.gradle.kts | 25 +++++++++++ .../plugin_integration_test.dart | 25 +++++++++++ .../example/ios/RunnerTests/RunnerTests.swift | 27 ++++++++++++ .../macos/Runner/DebugProfile.entitlements | 12 +++++ .../macos/RunnerTests/RunnerTests.swift | 28 ++++++++++++ .../example/windows/flutter/CMakeLists.txt | 7 ++- .../example/windows/runner/flutter_window.cpp | 5 +++ 10 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 auth0_flutter/example/android/app/build.gradle.kts create mode 100644 auth0_flutter/example/android/app/src/main/kotlin/com/example/auth0_flutter_example/MainActivity.kt create mode 100644 auth0_flutter/example/android/build.gradle.kts create mode 100644 auth0_flutter/example/android/settings.gradle.kts create mode 100644 auth0_flutter/example/integration_test/plugin_integration_test.dart create mode 100644 auth0_flutter/example/ios/RunnerTests/RunnerTests.swift create mode 100644 auth0_flutter/example/macos/Runner/DebugProfile.entitlements create mode 100644 auth0_flutter/example/macos/RunnerTests/RunnerTests.swift diff --git a/auth0_flutter/example/android/app/build.gradle.kts b/auth0_flutter/example/android/app/build.gradle.kts new file mode 100644 index 000000000..b02dffed5 --- /dev/null +++ b/auth0_flutter/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.auth0_flutter_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.auth0_flutter_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/auth0_flutter/example/android/app/src/main/kotlin/com/example/auth0_flutter_example/MainActivity.kt b/auth0_flutter/example/android/app/src/main/kotlin/com/example/auth0_flutter_example/MainActivity.kt new file mode 100644 index 000000000..c61bc7c18 --- /dev/null +++ b/auth0_flutter/example/android/app/src/main/kotlin/com/example/auth0_flutter_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.auth0_flutter_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/auth0_flutter/example/android/build.gradle.kts b/auth0_flutter/example/android/build.gradle.kts new file mode 100644 index 000000000..89176ef44 --- /dev/null +++ b/auth0_flutter/example/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/auth0_flutter/example/android/settings.gradle.kts b/auth0_flutter/example/android/settings.gradle.kts new file mode 100644 index 000000000..a439442c2 --- /dev/null +++ b/auth0_flutter/example/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/auth0_flutter/example/integration_test/plugin_integration_test.dart b/auth0_flutter/example/integration_test/plugin_integration_test.dart new file mode 100644 index 000000000..417f80523 --- /dev/null +++ b/auth0_flutter/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,25 @@ +// This is a basic Flutter integration test. +// +// Since integration tests run in a full Flutter application, they can interact +// with the host side of a plugin implementation, unlike Dart unit tests. +// +// For more information about Flutter integration tests, please see +// https://flutter.dev/to/integration-testing + + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:auth0_flutter/auth0_flutter.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('getPlatformVersion test', (WidgetTester tester) async { + final Auth0Flutter plugin = Auth0Flutter(); + final String? version = await plugin.getPlatformVersion(); + // The version string depends on the host platform running the test, so + // just assert that some non-empty string is returned. + expect(version?.isNotEmpty, true); + }); +} diff --git a/auth0_flutter/example/ios/RunnerTests/RunnerTests.swift b/auth0_flutter/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..6cf69842f --- /dev/null +++ b/auth0_flutter/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,27 @@ +import Flutter +import UIKit +import XCTest + + +@testable import auth0_flutter + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = Auth0FlutterPlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/auth0_flutter/example/macos/Runner/DebugProfile.entitlements b/auth0_flutter/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000..dddb8a30c --- /dev/null +++ b/auth0_flutter/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/auth0_flutter/example/macos/RunnerTests/RunnerTests.swift b/auth0_flutter/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..2285cb2eb --- /dev/null +++ b/auth0_flutter/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,28 @@ +import Cocoa +import FlutterMacOS +import XCTest + + +@testable import auth0_flutter + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = Auth0FlutterPlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, + "macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/auth0_flutter/example/windows/flutter/CMakeLists.txt b/auth0_flutter/example/windows/flutter/CMakeLists.txt index 930d2071a..903f4899d 100644 --- a/auth0_flutter/example/windows/flutter/CMakeLists.txt +++ b/auth0_flutter/example/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/auth0_flutter/example/windows/runner/flutter_window.cpp b/auth0_flutter/example/windows/runner/flutter_window.cpp index b25e363ef..955ee3038 100644 --- a/auth0_flutter/example/windows/runner/flutter_window.cpp +++ b/auth0_flutter/example/windows/runner/flutter_window.cpp @@ -31,6 +31,11 @@ bool FlutterWindow::OnCreate() { this->Show(); }); + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + return true; } From ce3967c4605f3d9468197cf948cfc1ab19618254 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sun, 17 Aug 2025 16:23:24 +0000 Subject: [PATCH 02/18] windows support added --- auth0_flutter/.metadata | 36 ++++++- .../auth0_flutter/Auth0FlutterPlugin.kt | 33 ++++++ .../auth0_flutter/Auth0FlutterPluginTest.kt | 27 +++++ .../ios/Classes/Auth0FlutterPlugin.swift | 19 ++++ .../ios/Resources/PrivacyInfo.xcprivacy | 14 +++ .../lib/auth0_flutter_method_channel.dart | 17 +++ .../lib/auth0_flutter_platform_interface.dart | 29 +++++ .../macos/Classes/Auth0FlutterPlugin.swift | 19 ++++ .../macos/Resources/PrivacyInfo.xcprivacy | 12 +++ .../auth0_flutter_method_channel_test.dart | 27 +++++ auth0_flutter/test/auth0_flutter_test.dart | 29 +++++ auth0_flutter/windows/.gitignore | 17 +++ auth0_flutter/windows/CMakeLists.txt | 100 ++++++++++++++++++ .../windows/auth0_flutter_plugin.cpp | 59 +++++++++++ auth0_flutter/windows/auth0_flutter_plugin.h | 31 ++++++ .../windows/auth0_flutter_plugin_c_api.cpp | 12 +++ .../auth0_flutter_plugin_c_api.h | 23 ++++ .../test/auth0_flutter_plugin_test.cpp | 43 ++++++++ 18 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt create mode 100644 auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift create mode 100644 auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy create mode 100644 auth0_flutter/lib/auth0_flutter_method_channel.dart create mode 100644 auth0_flutter/lib/auth0_flutter_platform_interface.dart create mode 100644 auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift create mode 100644 auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy create mode 100644 auth0_flutter/test/auth0_flutter_method_channel_test.dart create mode 100644 auth0_flutter/test/auth0_flutter_test.dart create mode 100644 auth0_flutter/windows/.gitignore create mode 100644 auth0_flutter/windows/CMakeLists.txt create mode 100644 auth0_flutter/windows/auth0_flutter_plugin.cpp create mode 100644 auth0_flutter/windows/auth0_flutter_plugin.h create mode 100644 auth0_flutter/windows/auth0_flutter_plugin_c_api.cpp create mode 100644 auth0_flutter/windows/include/auth0_flutter/auth0_flutter_plugin_c_api.h create mode 100644 auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp diff --git a/auth0_flutter/.metadata b/auth0_flutter/.metadata index fe4a72344..5a30c6421 100644 --- a/auth0_flutter/.metadata +++ b/auth0_flutter/.metadata @@ -4,7 +4,39 @@ # This file should be version controlled and should not be manually edited. version: - revision: 097d3313d8e2c7f901932d63e537c1acefb87800 - channel: stable + revision: "ea121f8859e4b13e47a8f845e4586164519588bc" + channel: "[user-branch]" project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: android + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: ios + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: macos + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: web + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: windows + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt b/auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt new file mode 100644 index 000000000..90b561e5e --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt @@ -0,0 +1,33 @@ +package com.example.auth0_flutter + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** Auth0FlutterPlugin */ +class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private lateinit var channel : MethodChannel + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "auth0_flutter") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + if (call.method == "getPlatformVersion") { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt b/auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt new file mode 100644 index 000000000..cbaaae32c --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt @@ -0,0 +1,27 @@ +package com.example.auth0_flutter + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlin.test.Test +import org.mockito.Mockito + +/* + * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +internal class Auth0FlutterPluginTest { + @Test + fun onMethodCall_getPlatformVersion_returnsExpectedValue() { + val plugin = Auth0FlutterPlugin() + + val call = MethodCall("getPlatformVersion", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + } +} diff --git a/auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift b/auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift new file mode 100644 index 000000000..539c9a69c --- /dev/null +++ b/auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class Auth0FlutterPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "auth0_flutter", binaryMessenger: registrar.messenger()) + let instance = Auth0FlutterPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy b/auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/auth0_flutter/lib/auth0_flutter_method_channel.dart b/auth0_flutter/lib/auth0_flutter_method_channel.dart new file mode 100644 index 000000000..2052b0b7d --- /dev/null +++ b/auth0_flutter/lib/auth0_flutter_method_channel.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'auth0_flutter_platform_interface.dart'; + +/// An implementation of [Auth0FlutterPlatform] that uses method channels. +class MethodChannelAuth0Flutter extends Auth0FlutterPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('auth0_flutter'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } +} diff --git a/auth0_flutter/lib/auth0_flutter_platform_interface.dart b/auth0_flutter/lib/auth0_flutter_platform_interface.dart new file mode 100644 index 000000000..6b77be101 --- /dev/null +++ b/auth0_flutter/lib/auth0_flutter_platform_interface.dart @@ -0,0 +1,29 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'auth0_flutter_method_channel.dart'; + +abstract class Auth0FlutterPlatform extends PlatformInterface { + /// Constructs a Auth0FlutterPlatform. + Auth0FlutterPlatform() : super(token: _token); + + static final Object _token = Object(); + + static Auth0FlutterPlatform _instance = MethodChannelAuth0Flutter(); + + /// The default instance of [Auth0FlutterPlatform] to use. + /// + /// Defaults to [MethodChannelAuth0Flutter]. + static Auth0FlutterPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [Auth0FlutterPlatform] when + /// they register themselves. + static set instance(Auth0FlutterPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } +} diff --git a/auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift b/auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift new file mode 100644 index 000000000..0ba101c8b --- /dev/null +++ b/auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift @@ -0,0 +1,19 @@ +import Cocoa +import FlutterMacOS + +public class Auth0FlutterPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "auth0_flutter", binaryMessenger: registrar.messenger) + let instance = Auth0FlutterPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy b/auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..918d80be4 --- /dev/null +++ b/auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,12 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/auth0_flutter/test/auth0_flutter_method_channel_test.dart b/auth0_flutter/test/auth0_flutter_method_channel_test.dart new file mode 100644 index 000000000..20dca7076 --- /dev/null +++ b/auth0_flutter/test/auth0_flutter_method_channel_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:auth0_flutter/auth0_flutter_method_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelAuth0Flutter platform = MethodChannelAuth0Flutter(); + const MethodChannel channel = MethodChannel('auth0_flutter'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/auth0_flutter/test/auth0_flutter_test.dart b/auth0_flutter/test/auth0_flutter_test.dart new file mode 100644 index 000000000..73332565b --- /dev/null +++ b/auth0_flutter/test/auth0_flutter_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:auth0_flutter/auth0_flutter.dart'; +import 'package:auth0_flutter/auth0_flutter_platform_interface.dart'; +import 'package:auth0_flutter/auth0_flutter_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockAuth0FlutterPlatform + with MockPlatformInterfaceMixin + implements Auth0FlutterPlatform { + + @override + Future getPlatformVersion() => Future.value('42'); +} + +void main() { + final Auth0FlutterPlatform initialPlatform = Auth0FlutterPlatform.instance; + + test('$MethodChannelAuth0Flutter is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + Auth0Flutter auth0FlutterPlugin = Auth0Flutter(); + MockAuth0FlutterPlatform fakePlatform = MockAuth0FlutterPlatform(); + Auth0FlutterPlatform.instance = fakePlatform; + + expect(await auth0FlutterPlugin.getPlatformVersion(), '42'); + }); +} diff --git a/auth0_flutter/windows/.gitignore b/auth0_flutter/windows/.gitignore new file mode 100644 index 000000000..b3eb2be16 --- /dev/null +++ b/auth0_flutter/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt new file mode 100644 index 000000000..b7231b658 --- /dev/null +++ b/auth0_flutter/windows/CMakeLists.txt @@ -0,0 +1,100 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "auth0_flutter") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "auth0_flutter_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "auth0_flutter_plugin.cpp" + "auth0_flutter_plugin.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/auth0_flutter/auth0_flutter_plugin_c_api.h" + "auth0_flutter_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(auth0_flutter_bundled_libraries + "" + PARENT_SCOPE +) + +# === Tests === +# These unit tests can be run from a terminal after building the example, or +# from Visual Studio after opening the generated solution file. + +# Only enable test builds when building the example (which sets this variable) +# so that plugin clients aren't building the tests. +if (${include_${PROJECT_NAME}_tests}) +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() + +# Add the Google Test dependency. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) +FetchContent_MakeAvailable(googletest) + +# The plugin's C API is not very useful for unit testing, so build the sources +# directly into the test binary rather than using the DLL. +add_executable(${TEST_RUNNER} + test/auth0_flutter_plugin_test.cpp + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) +# flutter_wrapper_plugin has link dependencies on the Flutter DLL. +add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${FLUTTER_LIBRARY}" $ +) + +# Enable automatic test discovery. +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp new file mode 100644 index 000000000..5a95e93a7 --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -0,0 +1,59 @@ +#include "auth0_flutter_plugin.h" + +// This must be included before many other Windows headers. +#include + +// For getPlatformVersion; remove unless needed for your plugin implementation. +#include + +#include +#include +#include + +#include +#include + +namespace auth0_flutter { + +// static +void Auth0FlutterPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "auth0_flutter", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); +} + +Auth0FlutterPlugin::Auth0FlutterPlugin() {} + +Auth0FlutterPlugin::~Auth0FlutterPlugin() {} + +void Auth0FlutterPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) { + if (method_call.method_name().compare("getPlatformVersion") == 0) { + std::ostringstream version_stream; + version_stream << "Windows "; + if (IsWindows10OrGreater()) { + version_stream << "10+"; + } else if (IsWindows8OrGreater()) { + version_stream << "8"; + } else if (IsWindows7OrGreater()) { + version_stream << "7"; + } + result->Success(flutter::EncodableValue(version_stream.str())); + } else { + result->NotImplemented(); + } +} + +} // namespace auth0_flutter diff --git a/auth0_flutter/windows/auth0_flutter_plugin.h b/auth0_flutter/windows/auth0_flutter_plugin.h new file mode 100644 index 000000000..4f1c82591 --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin.h @@ -0,0 +1,31 @@ +#ifndef FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ +#define FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ + +#include +#include + +#include + +namespace auth0_flutter { + +class Auth0FlutterPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + Auth0FlutterPlugin(); + + virtual ~Auth0FlutterPlugin(); + + // Disallow copy and assign. + Auth0FlutterPlugin(const Auth0FlutterPlugin&) = delete; + Auth0FlutterPlugin& operator=(const Auth0FlutterPlugin&) = delete; + + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); +}; + +} // namespace auth0_flutter + +#endif // FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ diff --git a/auth0_flutter/windows/auth0_flutter_plugin_c_api.cpp b/auth0_flutter/windows/auth0_flutter_plugin_c_api.cpp new file mode 100644 index 000000000..c095fa23d --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/auth0_flutter/auth0_flutter_plugin_c_api.h" + +#include + +#include "auth0_flutter_plugin.h" + +void Auth0FlutterPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + auth0_flutter::Auth0FlutterPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/auth0_flutter/windows/include/auth0_flutter/auth0_flutter_plugin_c_api.h b/auth0_flutter/windows/include/auth0_flutter/auth0_flutter_plugin_c_api.h new file mode 100644 index 000000000..cef4a62cc --- /dev/null +++ b/auth0_flutter/windows/include/auth0_flutter/auth0_flutter_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void Auth0FlutterPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_C_API_H_ diff --git a/auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp b/auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp new file mode 100644 index 000000000..e39a3a0f1 --- /dev/null +++ b/auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "auth0_flutter_plugin.h" + +namespace auth0_flutter { +namespace test { + +namespace { + +using flutter::EncodableMap; +using flutter::EncodableValue; +using flutter::MethodCall; +using flutter::MethodResultFunctions; + +} // namespace + +TEST(Auth0FlutterPlugin, GetPlatformVersion) { + Auth0FlutterPlugin plugin; + // Save the reply value from the success callback. + std::string result_string; + plugin.HandleMethodCall( + MethodCall("getPlatformVersion", std::make_unique()), + std::make_unique>( + [&result_string](const EncodableValue* result) { + result_string = std::get(*result); + }, + nullptr, nullptr)); + + // Since the exact string varies by host, just ensure that it's a string + // with the expected format. + EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0); +} + +} // namespace test +} // namespace auth0_flutter From 50ebfaeb6dbe3a19fdb2637a9245194c0c81cc11 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sun, 17 Aug 2025 16:28:45 +0000 Subject: [PATCH 03/18] windows support added --- auth0_flutter/pubspec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index 6a6226d63..4f580dae5 100644 --- a/auth0_flutter/pubspec.yaml +++ b/auth0_flutter/pubspec.yaml @@ -54,6 +54,8 @@ flutter: web: pluginClass: Auth0FlutterPlugin fileName: src/web.dart + windows: + pluginClass: Auth0FlutterPlugin # To add assets to your plugin package, add an assets section, like this: # assets: From f2d086688d967b9b5cdc206b91d204aee2734946 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Thu, 21 Aug 2025 05:55:27 +0000 Subject: [PATCH 04/18] windows support fixes build issue due to dependencies --- .vscode/settings.json | 7 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + auth0_flutter/pubspec.yaml | 2 +- auth0_flutter/windows/CMakeLists.txt | 98 +++++---- .../windows/auth0_flutter_plugin.cpp | 191 ++++++++++++++++-- auth0_flutter/windows/vcpkg.json | 17 ++ 7 files changed, 261 insertions(+), 58 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 auth0_flutter/windows/vcpkg.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..2bd7359f3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "files.associations": { + "variant": "cpp", + "tuple": "cpp", + "utility": "cpp" + } +} \ No newline at end of file diff --git a/auth0_flutter/example/windows/flutter/generated_plugin_registrant.cc b/auth0_flutter/example/windows/flutter/generated_plugin_registrant.cc index 8b6d4680a..9e7029719 100644 --- a/auth0_flutter/example/windows/flutter/generated_plugin_registrant.cc +++ b/auth0_flutter/example/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + Auth0FlutterPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Auth0FlutterPluginCApi")); } diff --git a/auth0_flutter/example/windows/flutter/generated_plugins.cmake b/auth0_flutter/example/windows/flutter/generated_plugins.cmake index b93c4c30c..930991068 100644 --- a/auth0_flutter/example/windows/flutter/generated_plugins.cmake +++ b/auth0_flutter/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + auth0_flutter ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index 4f580dae5..e35c883f5 100644 --- a/auth0_flutter/pubspec.yaml +++ b/auth0_flutter/pubspec.yaml @@ -55,7 +55,7 @@ flutter: pluginClass: Auth0FlutterPlugin fileName: src/web.dart windows: - pluginClass: Auth0FlutterPlugin + pluginClass: Auth0FlutterPluginCApi # To add assets to your plugin package, add an assets section, like this: # assets: diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index b7231b658..ae3819778 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -3,6 +3,10 @@ # version, as doing so will cause the plugin to fail to compile for some # customers of the plugin. cmake_minimum_required(VERSION 3.14) +#if (DEFINED ENV{VCPKG_ROOT} AND EXISTS "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake") +# set(CMAKE_TOOLCHAIN_FILE "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" +# CACHE STRING "Vcpkg toolchain file") +#endif() # Project-level configuration. set(PROJECT_NAME "auth0_flutter") @@ -42,59 +46,67 @@ set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) -# Source include directories and library dependencies. Add any plugin-specific -# dependencies here. +# Source include directories and library dependencies. target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +#list(APPEND CMAKE_MODULE_PATH "$ENV{VCPKG_ROOT}/installed/x64-windows/share") + +# === vcpkg dependencies === +# These are resolved via vcpkg.json automatically (cpprestsdk, boost) +find_package(cpprestsdk CONFIG REQUIRED) +find_package(Boost REQUIRED COMPONENTS system date_time regex) + +# Link Flutter + vcpkg dependencies +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter + flutter_wrapper_plugin + cpprestsdk::cpprest + Boost::system + Boost::date_time + Boost::regex +) # List of absolute paths to libraries that should be bundled with the plugin. -# This list could contain prebuilt libraries, or libraries created by an -# external build triggered from this build file. set(auth0_flutter_bundled_libraries "" PARENT_SCOPE ) # === Tests === -# These unit tests can be run from a terminal after building the example, or -# from Visual Studio after opening the generated solution file. - -# Only enable test builds when building the example (which sets this variable) -# so that plugin clients aren't building the tests. if (${include_${PROJECT_NAME}_tests}) -set(TEST_RUNNER "${PROJECT_NAME}_test") -enable_testing() - -# Add the Google Test dependency. -include(FetchContent) -FetchContent_Declare( - googletest - URL https://github.com/google/googletest/archive/release-1.11.0.zip -) -# Prevent overriding the parent project's compiler/linker settings -set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) -# Disable install commands for gtest so it doesn't end up in the bundle. -set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) -FetchContent_MakeAvailable(googletest) - -# The plugin's C API is not very useful for unit testing, so build the sources -# directly into the test binary rather than using the DLL. -add_executable(${TEST_RUNNER} - test/auth0_flutter_plugin_test.cpp - ${PLUGIN_SOURCES} -) -apply_standard_settings(${TEST_RUNNER}) -target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") -target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) -target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) -# flutter_wrapper_plugin has link dependencies on the Flutter DLL. -add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${FLUTTER_LIBRARY}" $ -) + set(TEST_RUNNER "${PROJECT_NAME}_test") + enable_testing() + + # Add the Google Test dependency (still FetchContent, not vcpkg) + include(FetchContent) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip + ) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + FetchContent_MakeAvailable(googletest) + + # Build test runner + add_executable(${TEST_RUNNER} + test/auth0_flutter_plugin_test.cpp + ${PLUGIN_SOURCES} + ) + apply_standard_settings(${TEST_RUNNER}) + target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + target_link_libraries(${TEST_RUNNER} PRIVATE + flutter_wrapper_plugin + gtest_main + gmock + ) + + # flutter_wrapper_plugin has link dependencies on the Flutter DLL. + add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${FLUTTER_LIBRARY}" $ + ) -# Enable automatic test discovery. -include(GoogleTest) -gtest_discover_tests(${TEST_RUNNER}) + include(GoogleTest) + gtest_discover_tests(${TEST_RUNNER}) endif() diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index 5a95e93a7..0bd647c7b 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -12,15 +12,146 @@ #include #include +#include +#include +#include +#include + +// OpenSSL for PKCE +#include +#include + +// cpprestsdk +#include +#include +#include +#include + +using namespace web; +using namespace web::http; +using namespace web::http::client; +using namespace web::http::experimental::listener; namespace auth0_flutter { -// static +// -------------------- PKCE Helpers -------------------- + +// Base64 URL-safe encode without padding +std::string base64UrlEncode(const unsigned char* data, size_t len) { + static const char* chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + std::string out; + int val = 0, valb = -6; + for (size_t i = 0; i < len; i++) { + val = (val << 8) + data[i]; + valb += 8; + while (valb >= 0) { + out.push_back(chars[(val >> valb) & 0x3F]); + valb -= 6; + } + } + if (valb > -6) out.push_back(chars[((val << 8) >> (valb + 8)) & 0x3F]); + return out; +} + +std::string generateCodeVerifier() { + std::array buffer; + if (RAND_bytes(buffer.data(), buffer.size()) != 1) { + throw std::runtime_error("Failed to generate random bytes for PKCE"); + } + + // URL-safe chars + static const char* chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + std::string verifier; + for (auto b : buffer) { + verifier.push_back(chars[b % 64]); + } + return verifier; +} + +std::string generateCodeChallenge(const std::string& verifier) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(verifier.data()), + verifier.size(), hash); + return base64UrlEncode(hash, SHA256_DIGEST_LENGTH); +} + +// -------------------- Local Redirect Listener -------------------- + +std::string waitForAuthCode(const std::string& redirectUri) { + uri u(utility::conversions::to_string_t(redirectUri)); + http_listener listener(u); + + std::string authCode; + + listener.support(methods::GET, [&](http_request request) { + auto queries = uri::split_query(request.request_uri().query()); + auto it = queries.find(U("code")); + if (it != queries.end()) { + authCode = utility::conversions::to_utf8string(it->second); + } + + request.reply(status_codes::OK, + U("Login successful! You may close this window.")); + }); + + listener.open().wait(); + + while (authCode.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + listener.close().wait(); + return authCode; +} + +// -------------------- Token Exchange -------------------- + +web::json::value exchangeCodeForTokens( + const std::string& domain, + const std::string& clientId, + const std::string& redirectUri, + const std::string& code, + const std::string& codeVerifier) { + + http_client client( + U("https://" + utility::conversions::to_string_t(domain))); + + http_request request(methods::POST); + request.set_request_uri(U("/oauth/token")); + request.headers().set_content_type(U("application/json")); + + web::json::value body; + body[U("grant_type")] = web::json::value::string(U("authorization_code")); + body[U("client_id")] = + web::json::value::string(utility::conversions::to_string_t(clientId)); + body[U("code")] = + web::json::value::string(utility::conversions::to_string_t(code)); + body[U("redirect_uri")] = + web::json::value::string(utility::conversions::to_string_t(redirectUri)); + body[U("code_verifier")] = + web::json::value::string(utility::conversions::to_string_t(codeVerifier)); + + request.set_body(body); + + auto response = client.request(request).get(); + if (response.status_code() != status_codes::OK) { + throw std::runtime_error("Token request failed"); + } + + return response.extract_json().get(); +} + +// -------------------- Plugin Impl -------------------- + void Auth0FlutterPlugin::RegisterWithRegistrar( flutter::PluginRegistrarWindows *registrar) { auto channel = std::make_unique>( - registrar->messenger(), "auth0_flutter", + registrar->messenger(), "auth0.com/auth0_flutter/web_auth", &flutter::StandardMethodCodec::GetInstance()); auto plugin = std::make_unique(); @@ -34,26 +165,58 @@ void Auth0FlutterPlugin::RegisterWithRegistrar( } Auth0FlutterPlugin::Auth0FlutterPlugin() {} - Auth0FlutterPlugin::~Auth0FlutterPlugin() {} void Auth0FlutterPlugin::HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result) { - if (method_call.method_name().compare("getPlatformVersion") == 0) { - std::ostringstream version_stream; - version_stream << "Windows "; - if (IsWindows10OrGreater()) { - version_stream << "10+"; - } else if (IsWindows8OrGreater()) { - version_stream << "8"; - } else if (IsWindows7OrGreater()) { - version_stream << "7"; + if (method_call.method_name().compare("webAuth#login") == 0) { + const auto* args = std::get_if(method_call.arguments()); + if (!args) { + result->Error("invalid_args", "Arguments must be a map"); + return; + } + + std::string clientId = + std::get(args->at(flutter::EncodableValue("clientId"))); + std::string domain = + std::get(args->at(flutter::EncodableValue("domain"))); + std::string redirectUri = + std::get(args->at(flutter::EncodableValue("redirectUri"))); + + try { + // 1. PKCE + std::string codeVerifier = generateCodeVerifier(); + std::string codeChallenge = generateCodeChallenge(codeVerifier); + + // 2. Build Auth URL + std::ostringstream authUrl; + authUrl << "https://" << domain << "/authorize?" + << "response_type=code" + << "&client_id=" << clientId + << "&redirect_uri=" << redirectUri + << "&scope=openid%20profile%20email" + << "&code_challenge=" << codeChallenge + << "&code_challenge_method=S256"; + + // 3. Open browser + ShellExecuteA(NULL, "open", authUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); + + // 4. Wait for callback + std::string code = waitForAuthCode(redirectUri); + + // 5. Exchange code for tokens + auto tokens = + exchangeCodeForTokens(domain, clientId, redirectUri, code, codeVerifier); + + result->Success(flutter::EncodableValue( + utility::conversions::to_utf8string(tokens.serialize()))); + } catch (const std::exception& e) { + result->Error("auth_failed", e.what()); } - result->Success(flutter::EncodableValue(version_stream.str())); } else { result->NotImplemented(); } } -} // namespace auth0_flutter +} // namespace auth0_flutter \ No newline at end of file diff --git a/auth0_flutter/windows/vcpkg.json b/auth0_flutter/windows/vcpkg.json new file mode 100644 index 000000000..4918e4e1f --- /dev/null +++ b/auth0_flutter/windows/vcpkg.json @@ -0,0 +1,17 @@ +{ + "name": "auth0-flutter", + "version-string": "0.1.0", + "description": "Auth0 Flutter plugin native C++ dependencies", + "dependencies": [ + "cpprestsdk", + { + "name": "boost", + "default-features": false, + "features": [ + "system", + "date_time", + "regex" + ] + } + ] +} \ No newline at end of file From c4c896d48b7ad35be9a1d0d35306627de3ec5446 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sun, 7 Sep 2025 17:23:47 +0000 Subject: [PATCH 05/18] fixes redirection issues --- .vscode/settings.json | 82 ++++- auth0_flutter/example/.env.example | 17 - .../example/windows/runner/.vs/CMake Overview | 0 .../windows/runner/.vs/ProjectSettings.json | 3 + .../windows/runner/.vs/VSWorkspaceState.json | 12 + .../example/windows/runner/.vs/cmake.db | Bin 0 -> 65536 bytes ...4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx | Bin 0 -> 56120 bytes .../windows/runner/.vs/runner/v17/.wsuo | Bin 0 -> 15872 bytes .../runner/.vs/runner/v17/Browse.VC.db | Bin 0 -> 614400 bytes .../runner/.vs/runner/v17/DocumentLayout.json | 12 + .../example/windows/runner/.vs/slnx.sqlite | Bin 0 -> 90112 bytes auth0_flutter/example/windows/runner/main.cpp | 96 ++++- auth0_flutter/windows/.vs/CMake Overview | 0 .../windows/.vs/ProjectSettings.json | 3 + .../windows/.vs/VSWorkspaceState.json | 12 + auth0_flutter/windows/.vs/slnx.sqlite | Bin 0 -> 90112 bytes ...f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx | Bin 0 -> 42428 bytes auth0_flutter/windows/.vs/windows/v17/.wsuo | Bin 0 -> 17920 bytes .../windows/.vs/windows/v17/Browse.VC.db | Bin 0 -> 565248 bytes .../.vs/windows/v17/DocumentLayout.json | 12 + auth0_flutter/windows/CMakeLists.txt | 3 + .../windows/auth0_flutter_plugin.cpp | 343 +++++++++++++++--- 22 files changed, 509 insertions(+), 86 deletions(-) delete mode 100644 auth0_flutter/example/.env.example create mode 100644 auth0_flutter/example/windows/runner/.vs/CMake Overview create mode 100644 auth0_flutter/example/windows/runner/.vs/ProjectSettings.json create mode 100644 auth0_flutter/example/windows/runner/.vs/VSWorkspaceState.json create mode 100644 auth0_flutter/example/windows/runner/.vs/cmake.db create mode 100644 auth0_flutter/example/windows/runner/.vs/runner/FileContentIndex/4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx create mode 100644 auth0_flutter/example/windows/runner/.vs/runner/v17/.wsuo create mode 100644 auth0_flutter/example/windows/runner/.vs/runner/v17/Browse.VC.db create mode 100644 auth0_flutter/example/windows/runner/.vs/runner/v17/DocumentLayout.json create mode 100644 auth0_flutter/example/windows/runner/.vs/slnx.sqlite create mode 100644 auth0_flutter/windows/.vs/CMake Overview create mode 100644 auth0_flutter/windows/.vs/ProjectSettings.json create mode 100644 auth0_flutter/windows/.vs/VSWorkspaceState.json create mode 100644 auth0_flutter/windows/.vs/slnx.sqlite create mode 100644 auth0_flutter/windows/.vs/windows/FileContentIndex/f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx create mode 100644 auth0_flutter/windows/.vs/windows/v17/.wsuo create mode 100644 auth0_flutter/windows/.vs/windows/v17/Browse.VC.db create mode 100644 auth0_flutter/windows/.vs/windows/v17/DocumentLayout.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 2bd7359f3..b08d30f1a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,86 @@ "files.associations": { "variant": "cpp", "tuple": "cpp", - "utility": "cpp" + "utility": "cpp", + "array": "cpp", + "vector": "cpp", + "xstring": "cpp", + "xutility": "cpp", + "algorithm": "cpp", + "any": "cpp", + "atomic": "cpp", + "bit": "cpp", + "bitset": "cpp", + "chrono": "cpp", + "cmath": "cpp", + "compare": "cpp", + "complex": "cpp", + "concepts": "cpp", + "deque": "cpp", + "exception": "cpp", + "format": "cpp", + "forward_list": "cpp", + "fstream": "cpp", + "functional": "cpp", + "future": "cpp", + "iosfwd": "cpp", + "istream": "cpp", + "iterator": "cpp", + "limits": "cpp", + "list": "cpp", + "map": "cpp", + "memory": "cpp", + "new": "cpp", + "numeric": "cpp", + "optional": "cpp", + "queue": "cpp", + "random": "cpp", + "ratio": "cpp", + "regex": "cpp", + "string": "cpp", + "system_error": "cpp", + "type_traits": "cpp", + "unordered_map": "cpp", + "xlocale": "cpp", + "xlocnum": "cpp", + "xmemory": "cpp", + "xtr1common": "cpp", + "xtree": "cpp", + "cctype": "cpp", + "charconv": "cpp", + "clocale": "cpp", + "codecvt": "cpp", + "condition_variable": "cpp", + "csetjmp": "cpp", + "cstddef": "cpp", + "cstdint": "cpp", + "cstdio": "cpp", + "cstdlib": "cpp", + "cstring": "cpp", + "ctime": "cpp", + "cwchar": "cpp", + "initializer_list": "cpp", + "iomanip": "cpp", + "ios": "cpp", + "iostream": "cpp", + "locale": "cpp", + "mutex": "cpp", + "ostream": "cpp", + "set": "cpp", + "sstream": "cpp", + "stdexcept": "cpp", + "stop_token": "cpp", + "streambuf": "cpp", + "thread": "cpp", + "typeinfo": "cpp", + "unordered_set": "cpp", + "xfacet": "cpp", + "xhash": "cpp", + "xiosbase": "cpp", + "xlocbuf": "cpp", + "xlocinfo": "cpp", + "xlocmes": "cpp", + "xlocmon": "cpp", + "xloctime": "cpp" } } \ No newline at end of file diff --git a/auth0_flutter/example/.env.example b/auth0_flutter/example/.env.example deleted file mode 100644 index 99a2d408b..000000000 --- a/auth0_flutter/example/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# -# Your Auth0 Domain. -# -AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN -# -# The Client Id of your Auth0 application. -# -AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID -# -# The custom scheme for the Android callback and logout URLs. -# Only set a value if you prefer not to use the default scheme (https). -# If you set a value: -# 1. Update the Android callback and logout URLs in the -# settings page of your Auth0 application with the custom scheme value. -# 2. Update the scheme value in android/app/src/main/res/values/strings.xml -# -AUTH0_CUSTOM_SCHEME=YOUR_AUTH0_CUSTOM_SCHEME diff --git a/auth0_flutter/example/windows/runner/.vs/CMake Overview b/auth0_flutter/example/windows/runner/.vs/CMake Overview new file mode 100644 index 000000000..e69de29bb diff --git a/auth0_flutter/example/windows/runner/.vs/ProjectSettings.json b/auth0_flutter/example/windows/runner/.vs/ProjectSettings.json new file mode 100644 index 000000000..8f0d73346 --- /dev/null +++ b/auth0_flutter/example/windows/runner/.vs/ProjectSettings.json @@ -0,0 +1,3 @@ +{ + "CurrentProjectSetting": "x64-Debug" +} \ No newline at end of file diff --git a/auth0_flutter/example/windows/runner/.vs/VSWorkspaceState.json b/auth0_flutter/example/windows/runner/.vs/VSWorkspaceState.json new file mode 100644 index 000000000..287f4fc17 --- /dev/null +++ b/auth0_flutter/example/windows/runner/.vs/VSWorkspaceState.json @@ -0,0 +1,12 @@ +{ + "OutputFoldersPerTargetSystem": { + "Local Machine": [ + "out\\build\\x64-Debug", + "out\\install\\x64-Debug" + ] + }, + "ExpandedNodes": [ + "" + ], + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/auth0_flutter/example/windows/runner/.vs/cmake.db b/auth0_flutter/example/windows/runner/.vs/cmake.db new file mode 100644 index 0000000000000000000000000000000000000000..442fd4033bb7ef3d39c8f5f74c7e5b509c270fc7 GIT binary patch literal 65536 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 literal 0 HcmV?d00001 diff --git a/auth0_flutter/example/windows/runner/.vs/runner/FileContentIndex/4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx b/auth0_flutter/example/windows/runner/.vs/runner/FileContentIndex/4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx new file mode 100644 index 0000000000000000000000000000000000000000..a304a758cec9dcf626531107708dffe4ee3648f8 GIT binary patch literal 56120 zcmcefcc5QIwYKNvgyiIRD2m|4N(jXSLI_nz0MkhzgpfiApaCg@(u)ny14Knd!G>Z% z1VqJ#ik0343t&Y7X(}k#v7+)l&)V+^@m}x!&reR?chBtEYu2n;v!?8Qjx%TN_Vj_) z*1Nma{|;EA5l0_#%&Sf~;P{h{KAJ)^_dMto zht5aV36oBI^@&|wtxY-T%TFv`@1DN@M2%5sMW@K5gO5GdT3`9t@BVPsI{#m4JiKe= zpLSpS@Eu0~XKFm^pd*eRZrQL+TifB~|G65EKlFrSPCEYJLnozmU9JB=YQ*i1J$A(r z2Oo3vB;cB_{0G}uE<5SaS06g*sDq9^;;=(c&|&|RH7@FEJpSOPI+3gGA7AS~<~b*w zc*Kz>{14Ze_{e|Gs$Gr$;x@E##preZV{K&QZ@bNZ8~^_wYh2aTFYeH_amB7(_x3}h z+NJ$%pw~u>ZX^5JP_Om%+CX1>T7MhcYtvdABES1-?K19G;gO@-($Q_isMbHa4UK9~ z8|70JJ9D6&LxHA1N{$`f7LRU|``Xi6JG;Li?RjI`$U&RdS2^f^(wNpasQ%OX+WI5f^8WV1(QVCvcGZ}+!C-r& z*S4(YcU9jtycJhlpJWA474?Aa$nmV zQGIRkh&I&U_8w?EmsaQZ+7;ZTB-TTXdc^J+Xmhz9)TCpvRj;kn*QSkV)A}{k24mXO z>eeRc(`)1U+WgUNVW(uZbZlSSbVM6F*e>a{52IM=K$oHa_?|Lnl>Sez93(ue*VZJb zzii$o(l6xm2!2A18}ZX!k8WcI+v&Z=l)gcWVP8)#@Y84aZPed}`r4ZH6pwG&HGDu( z{QRE$KFz|9weW+Rl`~w5g$LRQ+<+eBZuPj`J<8taLT4D$&a8YLk_&pduU&#OBeSmQ znuCzR`%bL4O;K4-8ro_W-+} z(F_5GPYEAiqiiyA28OFZGGl6__Mzb@FdlnSbzQ%@ns7QzD`}m1)ns)0POtrwV(2-t z>^7>+sN?{JN9cPB=~^+u9!FdGUhQ<&N@vctK*^>>51 zBGs%4HA8)E;fUb^z4q3wByt9{_ICXaPdxd@M5OBO$ph`mG40-7`v@BHV6Uymm?E-` zY1a+3(<|uhP?HFm=rF3D>M#GX$A_!NZSWI1=wE8Ddb=1)?F5ZYs2%4g?)A0(x~Pc2 znwT~!3C@xO?JKBM4eV5+$q0E$D+WTZO)lM#!}VC^+)-`!>O1U45~JZiSP~}$l~8dB zfjbV{jc8AM@#HhRXiT&p)Jl|`HeB7UgASIZbUiwc((D9z8atUGQ+@QNs%vR$>r~vJ zB1Sq9hKuq4E?Tavd{rvVhlnKD@V|vkY*Mz>@lc@G##9?uc>))mGjv`-lf7s)7Yw!w zdhItgJK)c#LBBkkIM4J)nCffeO6EIz&L;LLC4234+-mK*>K&3;VB|;x2)Z%RpbU|O z06VU)eYCu5M?&jh`(v+dTao9&{&p=hMMXwo_QE z^xDt++BVg9Os_xgYZK6L&_3N~pbfQlUtim5blU?9R7Hek9hG$ycAzgX0v=L97)|Ji z+gY7=vw45}T?G(E+1X>-O@r3;WNW=X8%4iR#RYUaZpPT5Azch_?KJKZ(rckb-)-93 zhekS_cVo>ksBipWWP8!*wso)33)lDBolJjyZR&`2U^VtU=8o33qm~*PEIc0SwGGRd ztac8<-~;V9W7>3vYHMrt+TZ9qjMmrYj%f4y+aSZXwI?wzT`Zv#iQTSm8Y2HUTD?dsKhavOYmq;)Yg&S9cwozQD9 zMfr*kSOyT#Nwd>#Q+On%rZPK2C&kg%hb;>gt2q4?T!Dmwq<8_ zY+l0+Z$A{xv2cG|6LU3}j@OQGh|zF*=k(!@pqa!XMhe!Td^g6)#OF6-XbiR$y~b+U zh)3w-sq&Mzm0w{T`tDk~51B}y?Rel|o6A5hr&11GUsgqp3B7hK!2#RWz)<4~4Qdaz zQLUZMdWM)lUr;Z0D|e!#E;ra-Qr^Pd`!PGhYos4Uqx#zR1dwhkQO4PG_1y%wy5NA4 zW7=6Y^v0GQZtrWo{`NqP;&W>=^|l%~*avq#w!b}}fjPPzKhQor#_n@~*SdufP17*x zAuKX$aBVQcwcj??!0ChS?>&bwyD&S8_R?SM3vkqW3i;D$AWM4MPG+Ayj$yIk3}OzA zMLrRjjaBsyBM1|X9aO^6EC6WH@C0;cuN{C}(jC3_{n2f`zIFl5Qz4F=DTEGY2TVG= zeb^ao322C|$h?qS7^Fr${X*ax!Ut;9;Q0)YZtUP==ODA+eTVCTZf<4b8y;d5pgG%B zgkduJTlF;|f<78Wh{MS-c+FY#BgUt7G@M3}iEgJcWA@zbUN@rcOMW*NaDG&!3>wmL zHNfA{+U+A+|H$_G(e2M;TK`~sQ?K2Hd$)E*IgdzSFaNZPzP3Bj8)wGygYBkX`$bjF zC?FH#Fe%(fD@Oc}?J7Zx1qn78>85+@0mJFmh@~SiemMpfrcAf9`vbnwZ}3@JttP5; zgMuo?(j(=TW3kvk3F)=DeeLzliHWCn8P&c}VH4;~DJSqOZG)!UK^uyk_gd9WxQ5$(UbreOC+n-)YF{EKIMl zU_<9F)L@6|6#M~~bB>-}{(}z;cPPr(DI*OFSo^kB7OoEM z6b6T?br>IOpVMn6)exY)9J%~y_{ilE`~YfsGLi< z7-(0N+n>gKgjG?g&k%IlKGeDc~_{*7Y8TXpdW#|8b2zS_X z;`Y@ewy4&+_|LSOzZ_qzPKka4?F|*o52crD-_0872LpBxkxGZ5%jns@wqQhiq^68> zn5{S>8_~AyZ|jb3zs2%n+UVLNuZf*o`yl$zI+{bos~%j5-D(~mVhzA(EmiSDy!WVb z+D&TwQp?ZqYHI9nQ%Yy_|3|OAil9o@V-^;-)dj@WC;HmkOSiE#PhDP}PU~<~bihRX zj9D#0;tUhYIeR!>InU@i+`|s+1;*oJG^7H>p~C?O$#~SX)}A}6?bvO6`q~lwZN@GEq#n4k!pMvYtqf%5Cd_gm7Zr90FAQo%F=JLwLqmLq%nGm!y$Sy|{`9#lq;~v) zr!Fj?WD&*ewqf}sn~R}dn^?n~GBiiA)xVU#po~vPa6&$-udPdep(>3U;|^j@uYHki zT6yNH2ihGNgaB2cz$RCV&Z7&E-)&yX_*94S^-mTjwbq@{vWRl(r)f1^Vp1Mt08TFJ z;Q+>4j7dJ$E}PRY=s&HCsQAo)2OVs3?O*n)=rg|3dlF?LMJh!!v(*+DPx^=M4@3k)=aMgBz>u&a;X-q5K zJksyVgc$fvJbAFayA~T1qa&PEkxansb`YIdMp=0dwcrnLsUvhuL@%s2qHV_`IB><@ z8;M;NPrg~v1`p|MOVds(O|SryHA8^9umFDb)X{x4M-Rt6Pyj<=2aLc)IVaUQi0WCc zQQ(Z4Nk^6C$zMtGU-y7)8BQ;qDUYaC`!yoT_1$ud2ve)gMHTUwhO`nRs5TTkvz(|0 z$JX3|+pJu*H0>6OwHoZhC(HOhttt4!40+m8GvNEM4-U_op^F0;svG{@!2h=cc6Ep3 zT(Ktia~LCd8nqybZ7h9M+kb4xLiBdbwJLyM2P}pw)*1s#I?!s);@Xf^;mg?A?^+J8 zrZ2QJRdB{zd%d;lSojHCt|BUK$sU0K@_+3F|Boei%}|%HX5$E@TUiY>HiI*(ThAQb zE@slH;k-$|n}VIl8EF5gtq^;?TLv9{wDAlES93Ya(7;cw#ZPD>W2c^F)H89=oQ0et z(Z&&a$eURM479fowm-9BuP$J(;R2qCpoaNr^m`4-4f@(yt^KBU^#2@eFYmQ;*={gG zyB!Mq&2A#YJ_M`2;eb3s&%dm>!!i;gvzuqT36u~~F3Q*>pQmWZ2WQ}n^*=zZ)qh#Y#Ep|@%+MBkMs zJV`VtIoy5fkcx;zU>QC{qMI0oWARTOA8bD*n6M1SjEtHZFVic$--3Mnq`KPxNY!On zG+}8<6JjfDvYNImhlmMdae#6vieaeUKzsI}+eXxzTa(c=q941AYCF5&3kTfGjG#tl z>R$VFnVMchy$yQpAf%P)koMFeFuQ&sPhU0{$C*oJy$!I-j4R7>Tu8_l>M6}Q<<8%1IzQQvkxNz8}`~F z&a60??WUW$7(!qdD)C!Nc0*%$2+$EU=Yk4x2xK61=UrHjCJ@$`R>snz!S+%dct9`B zDJWjF>TI)mfujS8(F@3215=a)p2auFq`MD9y&8{WnZM{e#P+v?yJZGuXX(4@WkJ0u zU`aslvkKyIMgh)aV~1zD3tOAX7{__=UNXBT4C{Xf?opv;C84IOqei67!nXJl-OV_{ z8*8%e4*h7_V$`U&611HuOlRnCoR6K3(a^Rm;PMhxD_qd13#u>3!HhT?7QUQ;Rt_+& z)&q#5ndpyk4y-cMn6_${hZ_H8@~ZW!gLHUzj2Z|;Rfdw+HM{wGqSyY!b;V({LcVI$ zCWU9G*V*ssM8E&t{RniQuGK7$pM~;u5<-AN&bqA}9)&R%M(edVvFxE|Ya(NL;NYU_ zIz_=UXY{ohBihqz-r1slMz%{F z%nr0S^F=|a#Ps1vslz=qWC&o`CAGL^XkdBT&Rp~?2N>&Pja5QVTm*!jRvHz~9IVFOZ{jIHCKTT2L&KC?3qo{5tX znbEn{$zyO=Mpn0DB}8{K2!7awsaiHLvgl>H5-Y7yO9l7%gk=qu8DSO$N)y5+Q{EW_#tPuB;_#f>Nud~H zhaA@}y><-c%gBa(#?e4~emB1m>yXCJKzkp3&5;XF@d(zamvKZ4dX4aVHO^KmTMEAn zQO)z=YpXA?;kBLq_y*2~kFDwjzR#(SKqhj|;#RkMsRp7U((ov}#W7f&i|J^oVU}sG zLXf+;8oW>7f!zr(9k+Vta?-V0*u=IY`r0U>L^pT`+vUCXXI=shwne@6+<#q4bBri_k1fNZ-caXwSeTa9yraT|FMO%%pwjuE8naF-XudCf+r9#8B?_gp^fw` zen^CzR(+~@F-WaJ+JxFI42A({YLE7@-bxa2S*!xY@I{q27AcFrg#x zMwZPZ+JQ8UI6u(VuD3ZZFIO!;XxCudfz#}+-%FEqMmLt(_A*-Gc<4Dok3FY*sn=_# zRK0JYidws(|5$pHky8FlzpN}TJ+eMBu3dtaTk(j4CJ z@CdD=F4p{GIkTm9B-eIt1}*KTIkvY5sW4^SE^QC0l@i{yYW5t*7LTx5V+vmyV4K4= zp2L|;jaKCE%_|9-+3k@_6bc{N+BpQ4;Zs4rk75K34YaRzp2-Z$;*i1>OKJY*Y(J|B z6r)kQk8oVjBk$|APgcL|Ssl02h^()6Kp+-tZCz%Q{hwFSevkb5yn!{YOhhwEM*9){neI>DqOG#nNfS~mA&==oi*HRM!asA zcWy(sGHh$sz}0C!!OHjVM2x}a`}b--Mx%{*1*+07D;fK>(qQjHaHcO<7vay$mBx?? z2+l-R>0k}#P67LZBvGZN4@`$N{DBq?RsQY0c0)Zirz|i?b*x@$Wj<|qWsZhfO!8%a z#X1K#)?VFh%3{1`eLgzZZW{GIw3>u?S6TQ*Mnt!6sSr1!NtOhN#Z?aG7N1NEpSjUHI0i^2@5iy^bCJdziZuMZ7EUyO8t%=YMo5=a zBk`$&eZIaZJ)at|Rlt*<3d@9d9QZ}}#$fvZj=|k>MNCI67&$wsGU*SyyX9jn3~(NM z84Xc`fktxmu~wdcVBsdE`FPy2eoil25e=T#y~*mFcAybpr+2TOYfO0vFsM)Zu8Ms< zN%m_@F%>1K`lQv*4C~t%ToLz}RY4NvF$>!SJa%rcy|5gZXHgYt9`)0GrSPu?oLe8P zL)!m-8ecvkQ_)|jw4#|C`5I7+=r^JOUOoJ>fVN;pT&SCB@n_uQ znX6BWt4|Ot!{~@w&k#V)s{xLc#$x~BeF~A*;auH~vr&wJ*~Ofjc=b#upe{^Z(HK=< zg|;-7twAk0h#RY3`h2J7r$jkauNTLx(y>2YJ%c3;`;3TNTu_ooOB0&cjCTvK`k;tyGM}S&3cUqx-67nVYD^C{bf)Y=^Ay>%|HJzm7dwH?Eo&r~*A(?V4-W zn1+SxsjKQe;L18qJRE`bChIG`whQg80=!=ys+N+R%UcPmuA?@h#1KA>0bO*DZP zm8z7)8hG79HB_(fR_w!X4|yjzwtRA0neL8q!pU{Qb~nL+m0@R@?kuaWhd4~&DY^qM zsxie(Jz{k~U$si4Ufi;R*uN|l7^*cAu^5M$Lp35e;rv}$a#qD6=6Vj$7(3MRQM`y( zD&0qeWw$A1%$<9F1g=mk{8 ze~ggbo6v5}wrcxRcj;mj#8(E^~er|dT&E3Jbc)V zU0vW^#Amv)%uDS!#zr+jFbIzj!&P{o7B-CNT9wg3-6V^s8GPy2g-6;>k5IS*f<9k* zb{89z0-f?S84V{Rgi1RXs_tM^=7UlK@9N!AVdQp3H?O*Cgnpu2{l&GtB{E;#*WT8x zrfPdNu3m=XKU6ni)d9k4UHR0PNO&3%6^G#~o#78`J~F&$l`;UF|}^N6ogRP z?tuoK-UV&SSHG`Ye$KDkaDc#|UU$$>_%Li-1>06PxmjSnSGy#fXZ07IE32jd!~sf3 z=e^gLcd>M#v*xix#6Rn;2RlJu=GLT!*$HvDjkgNvN1BgsQ0bX$i2n6e8Xw?sq;O9K zZQq&J3^lsmnX+=ekEtA=9MSjy&o^Q?^Qdn6w`8hW4CX|}%F;w>!hF*Iv$`hJU zwa)WTTkX}uIrZv+ecwXP`B98-(uU9c@q}J^2uJQH%upKJ9lUT+H6Fr7V>pT)%UC8F z6GAJF;Y+91#z*(7{SlFHC7I=oxHJuMva6GPr0I~=t@sHGyl1mSsr4iZe307f*@d(M z@5Php+2Qy`U}vJ|%_;W8;G6vGqT&wT3#>lrci)Wh^=wtgOwG4#r{VME64T4;oQ7x! zDh+t+rqV4#;NQ8&Pz}b36)WgmUR?S;L)mKYZs_q6xtqzIMQ31d8>_ z^7uCA&rG93ywI-@F`4{6=MGH4vXg~3K0pw|Cb#puA*@EwpBF12ql{-cWvaCqD;(@| zP^~Cz@#}ECp)>zACLG8| z*X7|f@ZY?r=v=xAOdHis$Bl8nTKv-xCe2g$6u&lZKgUo^0mE_F^$Y{B8}GIC7@~Zi z`e;mCv|c~quK%o$pcwuHWPE^&@w_y|AT{%@dW$_YJaF(#8`aJ7!=c{eubM7d=Odw; z-!THlKL*#-KeFfAg&bc<3>FuLOl z>`<2J$LnfgFqyXrGQA#sEcrivy~rmg26!$(t34xzHQAo zSJjCJqji(+!_m?QWf-LFws??OHwPCb%JEanV=jg&=eK2KZe3K-5|gg_&fywb(H!HP zM@^_fbSA$pQR^0lA&<~6nBpdCsj9JLXH0gBEa51?U4{meCUw1`mQww-#Okka68MN^ z3*4^)AnPScqU|`=Fj(@xeQHG~GFr<&t(w=zquP5}z|*vG+c`I*;|{A86d{`B;zHWnz1OHNqiI)HMMU6hSuHajOG8gVYoO8aerAO7 z$zy0Hc3>Y}(*y?Lk;(OK0G)dnZKOqID7UT^4^i)cdKpb~HX7mglU*C>ZYB$kI{hed zwUeAXupyIajmPZ~QtwB2mCSm8&*V0u#_njnPB88(Rr_dHuPvzel34qoa(NW=mCho9CqWP+g68KF1~{#g+Zg(n}dPpkhSzy607YEu6&QZeru zi${j(x=N1Vi#eLDd`?v={a<0$ z+LeA*?ms7KUP@H{^TM1}*7IYN-UwFrO_Sa%{DScKFkdN_oXx`ooJvoG)pJtPTZM7$ zy3br#yiIu9FsG}P&*G};-zn)S;i<`gQPR`G)59~uGsCmOv%~mevk&Gcy=T&#^H%HhglJcPCX3YyK)f1lupChgZV-;#>J=d(R5LE&TTI*|5(seyiL&!|w{e z8}>fOJazxQVHRB_|HDaN5dH{k`HPaiIQ-G@CE<_3Bl-8qq(24w+-H*hZ1}S9=fa3*R4pApBtXq3}<`KZC9J z;iMl4|04X$@T1|!!oLdtI{cgPZ^OR}|33VO@Z;e>hW`})GprteP5N))C&GV+z5mao zpA2g(?Zwign1w|#vt%)AmSRp68KJ3UV zf88*@*ivbJy`y-2*mC>^NAa_g|LpK{!h8i;<(~&ze#4~4hM8+Cf8(S#32z$SEc}A- zcvv}`C%px19NZdS175zao5O_$b(Vj!F91@NwbebN|God1+MTUzPN$!>>vH$>CFz z|GMz&!$aZI!l#GN2(JvkAZ$Yx!kjr(ns9p;R(>iul^vhe4^pAYi^MU}fe{KfE>!e0)5CHz&`ad>so*TBvLHza*y z_@?m9;akFA58nza=i9K)e<%6h4Sz5E{qPUMKMda${!#eH;k(24gzpXiBz#}^{_q3g z2g47Ae;WQ-_~+q=!;gf25&mWP(ePv8Uxj}i{!RF|;opUS59=3?C;i9ppTd6*|0Vp_ z@ZZ8ug#RA?NBEzxas@6umndeJP~0EpaIDg6gn1WI>Cs_M^(xJYd2ug17+y2HR(S33 zIxs&T>(z67*-`wAFk7=q^Ov8BpBetIFu$Ey`OgYJJN%q52h(+*ci+X&4{r$blK-mYzdGsHCVg`Fl<=wH*M(mn z9txipK0SO!cx9MVrE32h!*2?o8GbWtyx}aj%AFlPC;X1^JHzjS`DyP-`rI&wP1XM& zNcw}}55da6FzJiJ7l%I@z9jsy@W;cS2wxihWcX9zPlrDf{%rWN@aJHD+82_(Jp9G* zm%?8TeZv7H^7dsTax~I_}1`k;ctY$8U9xI+u_^8 zcZBZ@e<%E1*m{1L^j)xicX#qRQLg(B<^E4||KX$`f%$2_O#0FAW8q)H{P>+<$^Cuu z{{XuW{aeycg#QlP{~Y>Oc@Em@d44gl{B1O>zI?4!Y0kBZ`Msqo$6vmvG$&w{<`lV@ z4`S*$e#N-tKMyvZ@VCs0H-!0V;{fGv7T!EO3AX%Ju+a>Q*Sp83fE&ryZ-Bhy;Q?5Ay)YlnS9w>^V4=odRN%EG&}k8 zlD~U+kMMlha*JX0+$Z_VlHM=r{lok%j0!lYem1hbDbk_+_y5ygYnV z_}JV(4tBhpob)N-Q(>PQN}AUKRqq+_81l~wzbE(44Zj!mx%Vgif$#@m+kH{e7sK|; zCz8H2{K@dAVB7f_Sb3idU!MD441X#4UkzUg^W!V1vfFjA_1p|A=ho!k1}p!YNq;M8 zUcp!Xoyq@B_`Bing})#ELHLK^yTU&T|2TX%Y<>48{gd#0;rqi6gdYq)6#i-WXW^fR zABMH-uao{w__yKTg?|rg2VS=p|2_Ht2=lA1WiNikwCps3!j6~z@W}8Q;Zfny;eqg& za1XY7}r8_k|q~2PJ(7?DL0(4~La= zMEK?5SLFUtNgo|PCVXu8I9PcnBz|IvuMM9J``oGF*C&4{d|LSQ++UgW z8^Ui4zbSkstbNZ;`ke4P!taEw@7>{ZVf8vM=?}o#;e+81h0hOvID7%DoC}k_D1349 zFM*Z&iR51z{$%cdI_b}ZKO4R*_dg%LJo#Tt`pe<3CjV>UE5cWXuL@rsz9xKa_`2}* z;TvG}y*cSylD;kJZ-l=I>xXxQznA>)!`l5GSiAfr`S*qI4?mFm4<-FmSik&L(!b9A z-zNRLr2hc()BXae=M%8~^!M;TVC#J{X$PbCc~x5Z{YmrXZpq;nMe9DlX;eHI=5Jh7 z`L&Z?C+Vjry)JD1>nHt8*mgfJ>F0-e*;;ZoN_t#)oO5S#`^}jIpcMR{8`%}ZaB!5@f^3#*%JBX4qEBU*HchCJjVC&l}>4jmwFe|+l z!OB|-^V9YRe7|^b(nrAFKN7Y-j!ybG*mAE){>iZYb4r-c^(y_k@aw}vuyXh`sCXr; zerM+Xo5OEO{@Y>8p95>x_rU7&KG^!sOZtPb<<5tdcVX^dl>CpvKKJpYFAaYx`JaWA zcUkyz$-f*{-dDm`CjXl7^~t{(*6;alzvSEzzBBwCSp9#P^j)y}+@172u=@W5w!R0G z{wb`TAA|MFKfucQ3#`8UMc{hwiKPFY^gm&K{Gz7H@jX#7Uq2L&hOLh;a4XF(7*u-A zr1>SD(vJ@p>OOy7pwjCly?*$au=+k5w!X2+=d+D^j*ljaH-;_uf~3d8wu3+NUiT-# z>N7cMKFq2#e>t}9Zk$39yn*4u+`D&{2`K_NS&zDe*TWxJvKUpXD`5Lb7^E=*^ z|4i6&&r3cZdsY6p@W$ay!kdQq#JkG9AUrD}QD3`5L+Od{g+$@SDT@7HQpoYxu12+hFCMoiv{%m)v(JpFed} z&%HOy@90-`9 z_-xXbg+B*tr^{jO`c+uDSLFVcu+Lo^zA^dy7D4HAbNH6r{|2nQZ^O#DBlqtNe+O3H z4`9pvDEU85`rf2}0{i^^;RlodP|`nx^}9!t{}`;?--aJg{vToG{28`=e-HDUB$dx+ zpT+zozq-$-_Lb&y%4*+QVSXpS((8oz&b{=12CV$`VfA?qtR5S}o+C|w`DxoE&Hs-} z$=N08-IAUQD`)ql_egrr@Lu7)bAJ(Refz@3EB-EN)q60kT@QmDk1q=!9zFv0xnq(( zHhdgx{U;=SV$!b+zZzEV$w{9IEB|%j*N2C~r-k`t;3{_ptbG1@Xz`np&+kswef}6m zF~1~S{lMSotNPxV{CB}V|DN!BVf*ugu=c(n`4__Kby4y^mh{JS|5Dg?d^-0(m;5il zK6iQei?H&)mh=^{&t0ALHQ{Sv?Rhh-eqT@it>N2}|IMVo75;Yk_V69B&wnTB?}on@ z{(kreu;uPb`bXg(hwl#G6TUb6lkk1v`@;`}9}GVf{%QDUu=PEh^dsS4B>&N*AA_~e zZ^FM1|1tOZOTcAc{wQ|wU&4QdeeUl`^UED|pTA*H^*V^`7d||$G~el$z4?WoO7oWr ziuog^%d>N6YmIezV-?(=IJb)R3AtNguS%kk$CD$QSkDqa%iS8poK zuRBz^s*t&i{N>;7@cKR)>bbL%J}2pShxz4}YA1irqWJw`{)$2A`JtrG4}Um( z0nAUk6i~0vz_#ymu%oN~Xqbs>rhOY`=4O{-Yq_2m4?#85V3f~;QCH(d9tzo|7 zujjrAEARHC?@ao8uzv7;*!X=&$=s{DWD{&SM%Z-G=ke^sH%^E(A4Z(Q;>hL!h%q{rv}=1Fe>YoAF;^VfJw z-sCWUyQR|Gg!v`<Vf9!M zJ_y#%hlYIr{CnS9mjLWpwC;yBvf8wmlzZup~-x7Xn^4}IdJNf6p z#)HJs)8az2WY|47omO#0FAW8q)H+Tr)G zdj2W-e}>iXui+<>|M#T-ne>xM_pRBL?}ycYWO$9_k4}03w*7<2UkkRK>*fCXxxYc~ zKRfp~N_t~h`I{uYS<)|n)qjhmC+7Yn*!s2(ZA>U%))SHQOGz@!gO`jGIU$v-^lBf>9-m2-5` z$Apg!9|zmNuMD4@{8PfG!nSKD>C?ie!>%jNgw^*g$$xA3tnk}%|LmmCN&1~hzbpLi z@O#4N!pePL`2ETMK++#d`uwCX2w#}|i^3O&KN`LSR_@1>{zUjv*miz8>CeEHyDa%% zNdD!?|5DOlhPCe%NnaVhDtvYL8d&+)Cw&8~{F{=#8CK41Nq-~h+mpTn*4{sWmGh(A zzZX`{eX#y-wZbXZUt+f z?P1HmF!y(amA7-!yTH~n1Ge0(Z2OK(`Y2dA$0U7h__*-#;S*r>c_nQ5 zSHsFVB|HRc-_vsc4A}B-O!~~+e{=HB3ZD(D=euF$o}2vhVEgC8uyQZR{R@-62==*; zC;!s$r(o;-EbMca!OHm(tiE4M{uSXXVe7p*>1&d{F6rxG<=vG0TVUhww{!n?Sh?R# z`g=*=mGqCoKMvm=z9)PyZ2k9zA4vX#;fKOM4gW0s^YFv4c6ba{&)+2dc<%oZR{o#E ze@*`1VDVO?qe8`lrJ_Hyd^x zwP)@xfUR#~(tC#&g%^jHgqOmWUk2-cFHZhT!Yg3)dTG)JhYx{$?y#g^23zjsNxvfZ zj|v|HtM3W9e`5Hgu-<4>i@Ug|9kHLBmB?slVShxz4iBnN5Iy%MtC6kW5T`gV0g{&TCn=A z1FPqHuzEZ@_n#AfE^Pb9CcRO3TzKQ~CgDxPn}uHhThD}~HxF+So(QY|Rzxwb6;{ugxj!pBJNdiA)-yl6AouqQFNBr1Ba zcW}~&z_#OMuyT(``pDcrDtvVEk4yUaq)!UJI(#y${MWyo5Dmh>lK z+w=M4e<6H%_>18$g})42|5suA|LWvl6TUWlUHJO&4dEMMpT9ZjTf$!t-gnt;mEBvGIkHdF|?}3&7K++F}AA;5IXG#A&{BZb@@GoHf>oM5! zzfS&dlK*>H`F~FSU&4QdE&oLLAFy)#D;?&KfE{-Ou>LhB`GeuL!t3Pz)51>=uN!^_ zZ2cR+%6)e7p95Rp^ON2%_cwwqzgg1b!<)nEu|@7rPI~Ly-!{B`@^=WoFuY@Ur|=Y5 zeRfWIm+-Ez{y76yzuC#316zJBtbThYy;pc|Sp60yy(Hq$~`E2 z7;Jrq=l&7lmnZ+o@X^UXCVXu8xbX4e6T&CL&M&9Hj<+*l>pe5~-yD8R_^q(>-n)~2 z53FAAP5OP|^OFAoSi7AMYsU-2m*oD(bN`dD_WD%vKa=!l!RQ{&M&$ zu=;#0=_``HDtt}yuMJ-pzCQPFO!}tq&EZ?ZUk~3JzAgNX@HfNXg4N^pr0)pd8U9ZA zyW#JJzaRbqti67m^xffm!uN)M6232dKdfCI3jaL$4~HKK|04X$@T1|!!oLdtI{cgP zZ^OR}|33VO@Z;e>hW`Ys&tH@N8*Ci$|EyOY1$%!?((5F>Zg_oIzkDvNe>@M?t{Wyj zHtBImZwxDULeiVV%HI;!o?F8{zb&jE@0|Q;;aRZt%+CD~l+Vf1jlHg_Xa5 z(k~9LfR%G#_@MAhbN>+7=U$fl!^1~}Uk=-jV`24t4Xhro%l+4fhr*}9>UT!?4at9F z_)W0Sp9L%To#FSvKKK6c2gB#X>i^-SKa%u?NnaekB>5kMweKfk+x3~`e-^gh%VGQR zisWAzz6$oa>yo}cd;_eUo0Gl;R?l0L|IP62xqnCaPS|?x%Kf`x+xtNHVc7Z}g_ZZK zq<;t7-rpzx@udG4{!{qRuzLLswqN?!?b4%Q;;Wc69u9fsU;io5m-K5up)$`fO ze@^(hu=Q=2^w{u5;c?-O!<&RR4R02HL3li@o|`AVMR;QJC&AXYb<*4B{&wN*!#m{u zj!Ey7^iPk@#8N?19shSlqo+&?w6J;p0k;3%8h!_?oOi*> zJr`Eb_lDmWJ}>_5N|vcZcr@-wRv*KG<>( zCjTMWc0Qc@zexTs!;iv_x5tzIWB5(mR^iFvt;5@d zw+(L>-X6AHQ<9zvTmP;}zbHH{JRMe_nMuzI&rbesuy)xa`SW4r?G;`G`}~sJUmD&A z_PPCX|A6pIa(_km!0f85em)V_J}1HU!zoFh8h#z@^Fv9W7Ct?E z25dcVge`X_te$7({@ap&cJkkq{C6k++@#+deqZ=JSUDd``uy;R!xzBTcVW^Og)dJ2 zC9ryZBK)b`|8)2>;m?LIgO&IBq`wfpJo#UO)#q!;zao5P_^R;L;cLRzhOY}>AHE@c zWB8`<&EZ?ZUk~3JzAgNX@Hb)gx+Cd3!`}&iH~hWu_rpI3|1f-4_(!mIxI5{4lKx53 z_l55dKM;N}{1EK(KZCXZBgy|o_?O{F!;gi31zY~NN&hbV`|uxN<^B=2++V^^-!}}(G zIc$4hlJtt)e<^JJhlCHy{g;IgPyWl3ent4m@KL#cOwz}OkAv0Y#N2;n@?VwoYr-eP z>NS-6r-e@spAlXeena?;uzH`F^qXPjygm8ngx?9P$9rJ?{M_(+bN{^X2a^B6q|Xmu zko=Fpu75s}^rhiXhCdblboevj&xS7xe=hv_@E5|Dhrby9Quxc^uY|uE{#y8o@Ri}K z!dHi{311t&E_{9XhVYHyn_%sFOZet#B>10ehmG2n{I^k?!hajJnQcBetIY=I)b+f! zfa|4gF}SSFYvcHDLgBK)1Hg?df0K4{-Sh5*HWl2e?FvqA+ksp2*ILJsUI@OZ@~7AJ z%(fRfv)c4RWzMEXabIvwNmi3_ZEjlvs?Rvmv%x*&MchMHlyxEpl#obR(n04wr)ln7lS^#S=oQH_To0a zy`Xw$Jbf~Sl>H{oDV$fhv~U?{`^Hy2<4c}sj`GISQxgg&7H(NM6`Wl8Tf^gN^ETiP z{GSqxFFzTNw$`Q2;u3HMy)Yg{V4j>Zv0;*kEh)1!aWMrXCAf` zEx$);Xy1=7YlsWE-m{+Cv*a(Rw%GgQ%g*E5-j$;dj3?KYE=HcHKKj6T{!K6RDQzn* zF7)m`^{hTH9xt>-%M16%X5-P@wy)r>wwuboE%E0G=xXavC1-Qe<3TO3IXcV+tz`@T zn^3qLs0Ais---OEWgLgQ7VZXaO|G~@;Z$%tdO_T-aL2+q;P!Z#5+@*kKZNZv#%p zx|7NSCsEf7u4nR}xL4uA!bOEEK%m|9Gbhm&CEKD&q}6Ism9$-6o4>fO^}|X0Q^O^t!#=cW627-BsP9cG-xDWttp|z| ziK@f@&8n-#g(rjCV4`e0X3uNS9+`mepxq4f`^yqZjl{AYPRd2;oaxGmS)@lTu$?o^U?s+{TdlzlXr zdrFv7&(3KtSfMe)E)GUT;H=<4WAVa7S=k zMvNZjv)iMI+8YOVz!u^PP_4G9NU%-mpp+Tp=vmuP&XHm5omE8Z=xi{|+mo{`t>1>a z9N(tJIpB^}){(mn|IY~at|NS#y0=aB%Qon-CpjxfIV0@~YJn~u=ts6hjl``AE$I{6 zR%>nN46b)1ZmGwX;Pkq7EN@GVPxX_U+h;q0ou$=Xy)9#&=zlw+<95}4(VXen%AWRm z4mFCi(Limt=lRKn)1#9SZ3frVscU<#yq;Cpb3l7SdCo9K$L$Jt1nqHqXc?%~9q^bP z_-`kk-htAlY|#$Xy)C!`Pk$k;np}6bEVPu?cp-Ic&6Ry>JoQ?C7iWXoM%(QK>cL$< ziCS|`-F2qhv1(D`ILfN6xD)?rHElfHN?Ya~+p!b5UTYEYRB&eH%;Kpjv~jDNiKiFN zESyz1r*OBzd4=-}_blA2aAD!%!X<@E3->8pR`@z_I#Eruq_g}KbWp-f+BJn1TDvPB z(Yl<)rc^wcLV5w$^Q!#3k}$unjgV9DTAy+T7nQ$Pm3J1H!awtkLZT&I#fjEpCxid8_0ByCn6tvZ%Gd9kiwc*3v#R_o*lVTflcExw`$Xl;LF?&d4N?7ftNeLY(jJ`N zcCYe#FtbdjCZ)}<7TMEY?^>vaT2iz{t^`DDoL_SE0k5rhKK7eVeOhKuB8jLR?JxT5 zp7eugdG!#jWdXX2yA@h;LCV5I`>${b?gi*<@Id-6P?FqV=Hl9;Udu78Kf<*FU`Q0 zEZ_OD-r}|8^$l@3xECy%vlp!smx9_|RHl6-`m8pb!9Sl}S~@JPd@VBr3s~=RuH6^9 zh8<6yyOXJ)yAbCRM~pL~{?(11`E}iSoYA-I6I-u6lrR(fiL(p$Ds+YGUQ3)<=*oL` z$()TJdfk;#=4^VyF+8Er@qr!rg8HSA-4^Xt+U`{)?F)0P*I7xl6|Rj%pLKkRYGuEO*0PBEGpR!hEG{|v z`AqcNtWXV?RNI%-{!srn$MSlnT@Nu2#Br$E=#8 z#7)RCmW%TXyBM>CYyEdPQriz+ceY(Xl;5z;lx7M*R%0t(J`sj#O_J; z^c+SsYU+Wa(q>}^(N??f6P2&UMW3BVzl%QWJTCgIYZ%e;j$+ZX3ZuQVixT##e0|1i z%j+$omNznsN^@L`%5jDjmxIQ=Sv;dOW1Xn}&bFeuEkoWcN?Xf{vW$J*SxQvqih6cM zJ!|iqW2=pUo}5iAoC>~xyN=#X3O#{x1{YoDy5|v{pYFRbwc&&WbmZEl>U-FH& zv*|xus(nSZa~B|5-jz>xRw!ED_$z9gu5GS@y!I(qL85K(Bv4e(y-Koi+iT0XIufnd zF(~?!r-ep`7ZhsSMb$^17J0o@;kJcZdr^6;mrL1z19MYXt8K37om};wu`EM zS5scAm8&UH3oj}wxu!Bs>7SlRiW3Ue$2cxpm#ZvMkM(R*w0*9!L?yew5IxCW%)UW9 z6?DcCm1bNPmARzsW)$*T`Ae!5&PQHb%aW>P36}HPwm3tH%5a?~+M*@ZB3CJ1+pc}e zw%+qv`L5hV^>KwETDuWXRQ@vl6P2)Um3Ia2wdM77(enG2Zu?T(Y-*iYxPPI~?$6oz zY^@SF|5jlm(2aUfW(*m7=Y2Zk^pusaBkdWKW14S4Kt8 zK|L*U%{hmw9Y9ZaJU@D2;VjVehB@SV7VJGyEj%G`B+o2#j&fENmlo~=x{?)@Gm9Nn zHwW4RCFxn>Vo(;9GpFP`_YFq}SFK(zDD)}kJEMlWxoVojJ!@G&dyON$p>ggLeb%{8 z^jXJq&)E3zfNq-Vv?W8BkQF{@~8deCJ@%or>%E-LP|qbGdeA z*^SkeJKjmuCC)0`v(TrE|GQDb_Yk{Q8LesrTmrgM7L{YX70q#dD|Tlb$}}R1T})E4 zb{3VR{f6VIZ?BB1GYc*6ibC86bPVr?1|9m8(Q-HBnq#Eet*qxxRn3hIqBEp>e$m!B za(BaGKD)GX_9@NXD;dX^748o%tM=;iMt0lc2oV<|!?%3C!*MK5pw;43a3XD*%WUgi zPkLRuZvr}3ii-+e(P+uJ_@p_m@x-N|^M*LTaL+=Y)k{U6)k@-Sg_hJOMek}WcLg0P z*$6Kx-?i3UWO>*3g`(>eSKXp*SyF8=rg`nN#x8LM=d=Dp1ESyxhZJ{z<35ZH|T!?Dajit^% z)WDoQ3YF|SL3H)KpseJY!E3Ftpjz#k!Ru2%-w}$|ZZs1uuXl--bUeR)w{TVjlEfg^9tt| zc59I3b-e;K{)s-TUB!i<8v0JxXWgNQd&0(W(UIZKQ#3+2yNK?j9mAf%O$B{7=t*36 z($<}=xSKSVi30Ix%^*TC4%RA$XT}kH_ z*W04yT{DSV#xqG#Jv~41eB8T>%bw0?!+Qotf!CHXdW-h{66QNmosHW@6WiiyOjI|| zMnv!GZ?;Ft%j?|54JIiiXXf3X?#mx(?(N(rMwa|K9 zS&P=?T3DP{Xe(URiq`JBRdl8A3RFA=bUo+{+^smYw$F=u7y6WQh;c}J>dD^ssjjTE zxhnvzVFVjKHPBzZww!)0Dot+}^$_QJ*A3&oK~e3DZlbcC(?uma zo4e-FhOS6OZRmC4N3U@73+He%SBj!mauqGAx$hZ8t?jzn6^8Y8Uko_zyjFs37Jb$=gX7t|u9ijfeWNDYlifIX zo#3@kI}wEj0hd>ee&yh`5|&q){kSqVX-P+)=*gmMyM@eNeh29nBF@vkqjJs? zjb)B6(a7YS<@(GV-__XnmUoU2E$QtOls8+5w zM9=5kVdz_ycYKNN4_vQ_%6CMH)@#p;O7QDpaYCWb`oyr0>ZM+HC9Or@8$RvT(}w+6 z|FN7FFfxn#75bFXSe#nuQ^r_P-HfZw3Ns2F)B2C7wB=Q@+a-4Mhi3)dDYNMQ@CB52 z{7fkHw8Ax$=uG44#c}0b&tAl-pl2_`r_x4FuRTlNivAM4>(_jXY7OIeF;lqqeTQg` zp4B)e{VrDD5H0WCPgDo}MC@$Wz4^EGo>cfAS`YFq@bKBUXAxezj&}_sTE;jm>YMJB zMD_K}ooE^TP3+!;S-v|f^c>1-wQ@}_s+&G3>Jj$6xObtga}6WzQ|R~Fo|1N_nB94# zs6L+Ub!T;=F~GAi(Yt!G*qydSJ;XEci9YKy{-;N2AyKbT zYf%l2v7TbsQ$|$Le8N)x?v3Q8~WB6s@aUKkY+g8h+1VboFhA zXp|GRo4YenTRGB1rMdD@zX^p~7HT(FBcgQ~4@KMM=o8h`=S4MKfp(&Ly89EgmFIZk zR+RF~D)B|2Z$-t;3MUl0*6{7QIJa<*LQihGugD#_?ti9&zTXtPy-@dMfKPS1+-`Sd ztT9IklggG`q1ECV#l|VMax5=q{CXDRPDFI=>`p|qpFA5Bm9IrSX>#r3d5tq=_dZtL z9EqYDI{HLQcKh(B-olxq9Pf$tv+F2Pi)w4p_~(v5oLH!2Pm4wUv^zI-OzB(N!O&oTO41aCEXW_<_xb5?XTgrn>BWx-Oc>N z^Pw`kdB!o)^`~gN92cT>EiSux>h86*3|q)rY=LMk)+1^q*CgV}{PSE|RI+o8*nJVG zG*|ngIymA)<@m-;RGPLBwY;l+QEPbeFKTDUps0s*Gn{_!wa+^GM19XyhwljWQdj$; zW%erx`&GW{f7@UiJtY?{>3T&}a@Rsfme;0EW>opc@l^D4&%T5aH4e0g+yzZ2bWiM< z8a}D_#L8=<$$0*W&aTdkTFs}N8O5oE=5Nk_qF*aI?w@|;6OPxl?UqBS}{iq_kCl5)Ma6^>%x^?H)(J4tcJ zLThwfyKc5d-~5PLdoI=zCl_k@;rBes?oOp$4R~#=9e&H9O&s;2C0!YcTAwBF&F-@2UNMD=tA5S23=%at=6)ok6c7br(NbuAW^re30&JC;Rz zwmUm;6nov>?MA+3+_U(u-D}6SzfYnRXH`-CJDeS)P(i2@li`MJfO!PU|E@Bt8 zyY+=-oKZ#VbtV+8%lIm4F+EMRyklDQDMy&t?RdL!pnN?>G=3Xb{e7fy;5Li`e=EuB z?oE>Y=(X#)VgKuXYi=ev#v;)&#vgHU;j%*I>q)KxEon^X_S@aMP_(>zKyi;kbF@!4 zxA-R88uzD+yYz{LQ$c4I@A><9{3dfHyGdne)fS~mylnZ7e|&5-1X?b2_b@Z`?~vC|p{&9P~Fbjf4Au&exu#`1Pbd*?pH} zWb|tx$G+&bV_cjGI!=t6=4)H`Rr5ecpJ=^~GEZmB@hc>i zjt1);-mN)~JVAC08Ev(c*N!I9vsKqcqIW${5RLK93!-H_r4qX``lu@((S4mO9A_+_ zed=2$uYJ~&M}6A6u2@7@tgcc-$C-0S_dZs%UPqfjZdb_A!8{dX!P~*OSGrmyy!@Dju)%QdtbaSl{+-tqfRj_DX&Of3yACB(k z8yD`U6mrQho}BLQ5f9)3m$&1+?3AxBPh07ZQE- z4>0=Ri;0Q8=$j#i7@|h1FFt7e@hlJeY6A81ox9V$d+FZZ-9_n*JI&cUcV_OKIcLr} zbLPx+^7@kBKKOLy?@%Z0$IT^8W#qKNESR2iL?Sdze!qWh=klKu57X+cKhFn% z2Z6_PWsmR_4@LJc;;J|;W|BHV;G_C(v?xOym zKn|<=4--gE3Kx)<9dLT@%KcBL|0;{3|LW^8`dtmkemn`R1XNy4bAtoUr<3odc?ko} zzv{Jq&o=*>cdZp#Lp0}F4 z0=xj!0a}~ufo(to&+3K>)(6X)r1cClUbCXVqF?(U;Z`cz&{uJYy!;}iF4Jn- z!9QqvOba-(jN?2Wp!~tC*-LJWarGeQTdcf$U*&)6z%TzyIu;ebWZCsl)&IQBt3?0B z`#7em{{O%AzXO|-!~$s-ejeMCvb)bM*ugZrQSFU~$&F%tj#x{XHG62=OGy$-sWr6p z{dYUxIs51AP$f^l-c{01x!=Yae=D;r4al$(4Kw!_SdTQ{iht-g%KE3hgW`nZLlhqR z*B`BLeaP7$e~NpYVl-NNhj^O}w{X>zA%A=LF1P)(E36;Cq`RLnoMqij!x8b|fYpu( zyAlWKt1kn83!P}qRtnNT6aFwh5PzEXJ)-h|7kEFj*l))#zat3;#-Q8N{4nhD)#oL& zFPH~J#s4yGo&E>u_xv8oo-9$y`axmiX#%riSn4k-{+B}V@3(WFvi5I+c^B@m1pOuR zUvaSa<*<^1^yl$k!YA&tR@iGf^RM{7lq&7VqS7Cxe{Ik^0#?O=Pvv-##wrw(g3aL9 zo=&)xW*%i>sYvh46$R<{aLN<+BB3ov;uyOw*`(oIslPYPzlYiT_16{4OZVinE6M+^ z$*q59pj{rg-h0WNe8J@RmkpDPNU-`P8+v@u9HDptC?$uA*3l?Vz z3|eox`F;YelisPPR_vqvvi@mHkikY}ZJgxC{+p9M$&VMkclx!>-%Wlqwd_Xy#?OBl zx;C_G?ZTffn|H)EXCd6vu`*GS{7R1!uB$Shm3}WfQVD+9L4BX^V{A9CkyEAPkBlg! zp?uF^s-!H~SCn`8U-GFvZSwZhgUGGqyN1I@4$A-TCc0e*)LRWm)7yyE3i(2pD3kxv%?ha~>N%@5ZG7*QZY3hV1<>y{JQ(%V zOl$3GlC%~M>76lO#&qN(P%KJhAB_1Trc`8@Nl8Y0BuMg`WyBet$yo6|IP!~}7B$40 z0_`3~ECp9%BLBfTNMgHWkvy3)=1NTI7{F%BKWM1v$*U&mCkM2qLqE3&9af~bj$&C(9&9HSn9iu}6g-s4V( zM_qj9<93f0(TbE3zMW5`4+4!`95GNF`BnR_kAS%B!h?hfP2n&@fQnyRFX>RmibQSslcM7lTrxN^8PizZ6iQg`#tgh z#A_a{4R zI!KFh@awEhdy*a^S#A_M|0q@d5BTs0JiIkd;R;ycW2m(M6Ef7Eh zpeS|Q9fPuyn@l!wp3`yCaVK%I&Lr`)on+$uCX+ZbJBhRLZk%kqb|y3aI`cVRfBrh3 z?aztV>v%T%KXorikOW29NqeS~0|~7As5*7(SEo)@;dSPpyqw6#LrdxGN+cf|Q9_Ck z%GppzQ4|-Rm+&k;&f!2Wo*-3Aj(0nBDFb)Ep9HDq-%*|;=HHomV6NkP+y1Wg|7&@7 zb9cjI_1@YSYQ9)~u2l}{`s;@Mny32dY3)KcPh`K$XKy%xsLU7iYcmCw|5_)h5R-1N-& z-1X3>5FxDAm@ilTwwj$fpPHUqSRB6`vh8DAp3vg_^z4NYY-|+0vte|7 zu-H@{r>DERHjb0=TrM1oC*%3}=^?;N--)MS6Q}pDrV?b}$A&`L_)7Y2JRD0zqS-_~ z5zU1LLuDV)NIrfWy&cbmx+Z!;mwQ4pJ)zm2(AA#Md{5|^p02W`Lb*7`OeCMqqL|vX zzx$=2yXE+Cwa_GSY>cwfK~wf(aBF{*&9nEAsR`I)_C&eb9i!hS+PNBdX#L;$c&X}e zw+s!bg{g8H!Sc6hgDV{m)#M*ISnm7n;oDYD9WwfD`p_l+Llt`Dky2=AceiwRdkWDq zb#JZKsc`E!*_x$vn2_tvZ48P>8X$SNwQOW%e|NME{j*OksKq&GIg!eTml7CSa^BcH zZFjWVK7(%NkOgRR*}NpDmF_})tGngNfLfR+)}5mQ%v&ba z++jyV_iVG#($%7_ls4QZOF42a4{4yCt=N{FgR-j0-E!c7T7Y3&K-&x%(@( z(#dc(o{Z!Z=~Qm}dD9lnw%>(%x%6r_8V@^n%IX=(-j3%VmSb0K`$Dv?3gr_iEEYH0 z-d)HxNT?5bw>DyFQ~#x0V|mq=a-_B0>~iW#`sHryr6n|s-;QTXdMLfLl#Ay>J8vZ8 zsoVMGGB+Z54D?&8c}!|M8-1qU-QxGF>x(6gb{d>(DAQPKC>pZ8`q0Fc+4+UJajc_4 znLAFug7l_ z9t#tR+~#537FA1bJd&;@`FAM09LX*3)KhlEmfq-oFkT~P>+kKCdftlU;^AmAf<+b< zIaqt;c3!F7qV>-EP~N^+;^(*PQffgbmLAiN`i?w|9-@ggm6qtz+O(THqkvs%FWCD0FxSx3W#!z8xqS)$W!6=Gw6AL+O5s zvjftaE&Wxzi(cB8?8@MhYYS}m%HG<=!F4}qs**t@x{})Ago?M5iB!DI z&3KAdHM^9go4b_TYEQd0&-RO3jHa|3?G|k}koCbz={4VDPjDJgVG+Z1egfIq%KvJAC&p zqie$z?DTEB&Z#Afft?fX&RGj9!uuI}CMz2Gu9j>MkzH*0I~@b0Qh1NSSCVI!+GUQ1 zs<4q`7YVe%U%|@^OOe%NK72QlTrE;5bEmArvbVO&aB`MbT0Y-S<#*3lPFiYp z$iCHsl(rTg%xTfaCTW}7b#9|hd-J%%?Y`u7x3sjV@3oiPlA-+L+X7NvdL*YIbMEjL z4B@13? zz>9}Pr<>ccAnqSp>v z^rqkTYxdT#qes9Q^HAY zZ&)(%mV31Yi`C^cHf%fly}MsGTE_jVB43Qny^zFPt>MUOJ}u8T>Kbw?brq^qx$G}& z7;7e;&Ep*+*qU=BwzhBMI-0n%{_Sns$ZhMorCpoL%uRzE5k|{}eY>i^DQ;*}LCt=j zyuZ2%2j1YnQO(boziXa12ZP@*zZCp8!T%ckN;H@FhSgmz6$+ze6eKdS*Ep|VjU5z)^ zYTXSRpNEj2)EPQ{tg&Y6)o3b@tH%tj`&u-;vVsr&*xx3}T^ygdv~Xqe%Hr(gmD#Dq zRPjSoGjV*gXaXOLN+zQDMwiyv5>G8h@DZ38edT#6f)5d`>3UP)4t>~X;^d7*d@nn9 zV?2g0cO`Op`7O{Jlj-OxRh7Ge4;CyB_Aez@^Y~J)eHJdB#qZ&(Bgy!UH8d@~mb;N% zO{I`&;O@rZ-J#<*&Sh~gKJe7UK0YT~v!t)D0aU7(7}hG{3*(!r<_+ z2CvrHl#49kNfC(@GCcp%k025#WOn?b60Vco%-ev^qv@ZMS zbBmu*sIJvI)zWw1z4cnRy7dDKwc9S?(+V~ArP7Zo7$m9mQwT1P)}+$s4|JJ%%Lf9q z3eB%Np9K(LSX-(Beg9uKFDiKF|DJi#{L{A?V^)_5FaajO1egF5U;<2l2`~XBzyz28 z6JP>NfC(@GCcp%k025#WOn?b60Vco%n7}_OfdO5g3Dlh%ojP%Da%Aclo=4A(PM#PZ zof;cII&$v#iLv3qb4ONfC(@GCcp&VW&{puGwL31xexj%(_SAD&`UDy z`ndqDB-5@R2%zu!AKXhP`u_jfhCf!!Pn*At-~V4QJA+>d{;$Cw55|MXgG%5(1l9xN z0dL2D?)cS?cXd435%Pb{|2zKg_dnMimP1g9$JJCcp%k025#WOn?b60Vco%m;e)C0!)AjFoC@Y44&1M&Y9KJom6@) z6^_ND$-Q0x966&ay)!FI(Qq*uD!y^FSD~TDb){=2v64w=^U1`m@FNr%IIS!DoFb2u z|1rwHl8&t=k?j3PDKL6cSB}iY61hwwg;1NMsCITG@v8Hgd~$*O~u0TR4lQym+XNdG6$zX@)7b6mgdjx+0elw zgxr>&SW3m>dur4-peuom5_{=?q<^!)iKRynKiIdaP$nC>y%O20iht{rMC$fl1qKgqw!wB5dzBdKqS57aLn@kFjqTlxL!{M8B$`c! zVNQE@++ZhZWfws9MU{Z{k?Mc z@6#0%9xIYe--a96D|=svgs((0;Y2>ZvRCddQ&$=&_g)!~1$AX;CX&x*6Sr3L@o*%2 zTZXpYt6YD8wBIPVSN6UR(*ABFn~2;>?#aB*Pq~*?Q_(y^VDBY+pq)mFG~q{@BagLF zlWhwQFC*M|JPT7x?Ag6Nt-8|Yl-oTM#Qp1+ToV(-Cnq>)CKt=1z+?`fbm+w=Bbn)KI`No=UUSN4HA>b6^vTs$nN(!KKc z*4mR7LbYf1?5krn)W^5u2)Lf0wNQ#ew_|0nS0!hvb!B1(11yt-ZwPN3WFlGgA{G{V z3aHv5AeByqsc*uMEZ|h7uAC~-;M*!ZT0yIz4Xqz_Fyl++7;nX5do{7&Oa1)F4I1!J zKgUyWE7?eX?|G@mEln=Ia%*qeBZge=%Dmx6^%wmv(tl%_xL0!z>$=h;_1`OJw?NfC(@GCcp%~TLdm!s-cXG%y@3xICEohcH+ik*RIT6 zn!h?eG4*(vOC!T$gJWaICytJt7&NfC(@GCcp%k025#WOn?b60Vco%m;e)C0!)AjFoExc zK%1pDC?3(Ics-lNJ-SLC7ohk5mC)v8K4Jn)fC(@GCcp%k025#WOn?b60Vco%m;e)C z0!)AjFaajO1egF5U;<2l2`~XBzy#iY1nB*LPkT%;&j!CY@Sg&G9WVR;FMm5O@Pi33 z0Vco%m;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz286JP>NfC=nEpfGHCDiz)B_7tA% zuJg<+|JXMlJCpvckAJf22l~!TeBaNEH~jwhPMo~4I6pNve`9=dW_osdeqnBW;mX{N z$tx3!GgGq*xW2e>aj^gV<;8`CsX6;BTs)h4dVJ>U<*6Ijre`OwT${f!w>UeCOgJ4G z4%-(8E^a*=xOi@K>cqLpk>O*LBU4AujZU5z9-SH+KRR;m_=&OM!E;9oW0u>|da=LB z(fXtB`SaGJ{|Z{0|1$rr-tb$l^~BYyTlB{H0G@m5e$X-;-6#A-;?MoyXFvY+Pygnb z&i09W2mkDoZ-wrQ3)7e92QE6vBQWl%p&95?|klmfA`g|o#{UC@AA)&9)9ce z1V~%cN2S_h>5jJ3`-)V5Zs7A@{`2uGXZk+<|NhL_>zCdNZRe)uuPn|@ObuMzJbDDS zhb_&~b}CS0`xn3W_YIk^SDuOVRsJ87KU4QsXgf1LJxexPOdrW!k6FUeb+Nxl_8UJP zYX16f|G=54@z+0iukEkj3SAusvf(4PpFaZS$1K&+{Q3}4{<;4raaOpMAw2YR{kp1q zN>ALSCr0UstMo)KJ<&=}MCpm@LKUj{P15(E`AxGm__^Q@1?Pk9fj0s_5x5!H*YTGf zKiBb&4%7c>|1bGp@Ske`Z|%R@o@pO$`&!#CwB2dzZT(W~FSmwUzTEP$mX}(Fn*YA} z6V2~$zTWI_dZX#prkhROjbCW|VB@XE{S9Al`0a)tZU{H@*MF=2H|ww0H`aZ!?nmpQ zbw_Ley7oi0H*58pKdE`GW~pYh`kU1st$wcBsQSICAFO(+s=4y_D}SVNy0WX{OBEli zn6GgAe%JS+uiyJ6@2_~n-X_lqXviqYfxg6rp9^R9ON zGy0F|SM&z$-)b*v$HX_q$Ha@`xcay1$J7_qlho|*^l{pHsYMY&RS)>R!Yfq#vqd%O zZv7p8v6zWP^6`b`_)7e4IuQ#YLxUP>P#Xej!z%8bvR<^yo8`-2NlnIc`E2@rv2=}E z*Pzz;)tYoF7C&j-w@U|hD?O2oqyAgzbdsv}sZ|ZCuU++}Q_<`h>z*v^?P?>n>7R@P z;U(4Ayf3UK^5JwU98IQkj)48DF`^n>@-N*!YpvPUcbBhzKAwLP*{-A}NJg;*hU#fh zjgV^O;`tX&S$7}W{tNN^#Yifaj9VWdq!}2fP7`JkE z?Y-q|NAo9^6Uo?wEyQWR@>S)V4a!&dDPKkVqmNrzyZ*LLy9-J?;RWwnA{9%o$?8#K zE+10+l{Zf-Z;r{oba&Bu!B*nPnX(251PjZF-1%&J#RhpTks2AsAIJ^$fJZ2mj^B&M zGkN7r<;#BM&0ghAfXSb*GPYcQ`NnNEOXUsaOAX2!ZOR)lnCrNeb{f^arBNzb)*Wv$ z6UjzaE?*s=8=na|%?c>LaYXrz9{E?N4C7YnVKUaFjBDFu{Q42)*LxKDt5Jrt)`~5| zzZI^d$4Wexi`^9d(q&slIiyS8GDy3WpB+G3v8&m1v>ek{l@FWBtDVZL=%LIpD{jka zDsSTR%jq@8b1JVW?{83EX;xmz;o>PPX1AcJTXi1Nb4t64cyoyt;7M6Vt%%+FHosTJ%!S96nQP0;@sjeB4KKC66onfd zwQkzwI_pqQDzK&8%Ows*6?gAf-)+15GP>=k6}HPAC|_<%v24CN+nPnGks<3HvY6+9 zLY2{TvpQw8q*S&di~m$+!g|iG>NquRn~Kk1Qch*#w-dR1Jd5J+gA>U}F6X%Cl}yLV z;L0j;T(EA~Qo4uAN`YrBDQ#>)P3C{nx0ix%^|+Gd7P=MK)|Fk79#1B(z-Xxkk@5H1@1@ z-B#sD+s@*1?uIC#2p93hV_|y$XbbxmJPWN0bo&YG>0)cjiFk6))@XCxbD_DzbCmI% zbxjJ>N9)Q6Gjv5Xl!KxQ?dpN6+STT()Tk-zDO;oC{>G59@Dj#+>oYbj(nXPg{$& zobI8ra`x(RZF>K7{j@nveLia~NLk{*nR2qk1qhpV`n_BWr3q6X(p3Qt;dB*m+aVmNId8j4m^MJ9`MNn5SxGDRJ!9}_RdvpK!Y*{YWv7QZ-!XAJ3^fyX<6-K?^jbKY zUQOktkYH!9G8hU{h6QU{3ehedC@18gqTPYfUd_gr;@NCGb`Dd~az2H{2|UeHk>qN; z=>61ZpYuQIZ}T7b-+|@MSr=`w7ruS5m*V$JgtlF1Z)+QEBcW5)1zTwUu0l&}c{Z|A zs$9e6hPH;o4R-)z%sTIYljC92q-m+D)N3~*sdVc8N_sWdpMsEFCK8QzdB*7SfHh?o z-M4Kt$f8{$8@GFSVA*Ig=;XLHY3qEXYytSXrQ~WJvy#(Gxq)T9L#Jy4)`VR$uv#;b&rhUR zRCmwfZJ2z@1IwiBKy;3@M1>K4cd8+@32Q*S30%u z_|^IF?9|f>;rZ(`Q3>IyzMLS9&YWN|JEa>bE7F-b;t#H|%UBDtart6`=|ean$OwMRb-I zF+DjA5?901XoaiS<#jc>60t=1q}3~nc{{hR1hA6O^e!*$DKRBW?;#6z}l#dk*1Aw;s2;WN9_fyM2smC zG8xYfEQ>xl-Ji7f+mcS~7=%vUj)WtzNH!nNWYf=MHW^snN0*OU`|S1}*s&QDdSjgn zbc?U`wNxIi4B`pxM^>zmU2G&>w5XzMM=^IpQRhu{F*)Ovgw<5yh1GaClg~cpWI1{A zJk-FFD{7O#fQ&d~hT<6{L(8EYq$put-wPClzS2ERP#*pMLgvTV5hAP zTL4}Fa~@kXDEA6j^H}pRp7PbNQOoZruw!QtqIhTadPz}hKHE~!+}cb!K4!Jq^2{B3X4rs~xBa~bt*N8Q(^T6;?>wBcTJ7S2 zhZMJGVCN+?t3aw;oPdEjEPi9eY`-yz^zps$jp^tzZXR>#wb-ZLNJ77BXu! z+hzUJMG(cUq0pPWF2|T)yLh?0tVPiaay5_D9K(|`pR$_lrgW862Jh#%wyRU~cui$Z zYYj;su^OqgSM7o)*t!Z4x}t3(jves!4^wfnHB4ar6AX{%a_@a`P~8 zJ*<4WQEwg{I@*V)9QBV`m3H}^qn_$7Gw6FpPK}J>DF^+qRbdz3`Emvo-+ULr9O)W4 zh^M?_`D7t_2Vt9Ksk-hLdk**E3$-JG5z_6r<+VlOUB+^vwvY6l!KR)-4~_FwhIRMHT~?`AmKfJfQRo&%% zqyZjupLL(alQJK-T(&A*oscYy z`E(|qNT(u6?EL9)*i!Avu>?Yom5$ZY%8Pq{^vMD1CHnquLb`rZu5f)FaajO1egF5U;<2l2`~XBzyz286JP>NfC(@GCcp%k z025#WOn?b60Vco%n80_JK&>uRQ~%fEbSQCJgiD)*R!|9D1_NSY2H*QR<;`IZ<9rCbG&ab7FBEOi2ZsiSpea72d1PpCaHs-@E&9OGK^)r|`iBOG z?V~=*irxtYJMG{9|E&4Hng7oGu=xS=eP+STnlW?HykMR%`%KfUGu7Z%@mYXB4gUM! zM}t2f{L$cgFdMuToC{6_k3s-Hm;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz286JP>N zfC(@GCcp%k025#WOyGZf0@ZH$Be5#pR&R-#(T&p&iI>*X8g_YL`jb3R zwfqU|jq{8lf0i2RdFkib^*iz>l{aoL)ytm)-Z+mN@>hPVNPRr?h=HHIt)#+qb`!s4 zyKxf6FVt@2d(ObG#%`S5sKqb8ZV-7^9KUwv+Q!XiTkun^8#k|u_Jz>G#_1DcfAC;% z<8)e_@?Z6DoL&?s+RnFcoL&%p4VM}=6!PKM9yV(68u$f=jWdsdpJmv{=*AB%Y@E38 zTLh*1egF5U;<2l2`~XBzyz286JP>NfC(@GCcp%k025#WOn?b60Vco% zm;e)C0!)Aj>`s8b|F4?gQ1G80On?b60Vco%m;e)C0!)AjFaajO1egF5U;<2l2`~XB zzyz286JP>NfC(@GCcp%kz}t*~TN6Uxr#JHV|KDaE$LcZxCcp%k025#WOn?b60Vco% zm;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz286DUJ~-~TTIg)cDyCcp%k025#WOn?b6 z0Vco%m;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz286L^~u;P?OEW*x@rG65#Q1egF5 zU;<2l2`~XBzyz286JP>NfC(@GCcp%k025#WOn?b60Vco%m;e(fLxA7^F9U@yF##sP z1egF5U;<2l2`~XBzyz286JP>NfC(@GCcp%k025#WOn?b60Vco%m;e)an-Spm|KDaE z#_BQwCcp%k025#WOn?b60Vco%m;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz286DUJ~ z-~TTIg)cDyCcp%k025#WOn?b60Vco%m;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz28 z6L^~u@IhZs$L}lVkC}&q|1o$o@U6i614nRyA54G=FaajO1egF5U;<2l2`~XBzyz28 z6JP>NfC(@GCcp%k025#WOn?b6f&VT9mi5AuV&deD#rdhZ`5WVtGt;xv^9yt13s>fD zOkSB-oSB+k!1cw2i-Y~=FE1`EOwHM6;o{lU)8jK&FHhaLHa$Ce<=Xs>xy9L8WWwpl zaM->$a8dU>uNM~I`ids5Ue(@F`|0m!dqAawZawC3)62&9dlFj zR~F|crqB@mmR^{8ODdTepPn6{c4%)|$2$hNX=5U(7p}i0t=U!;wM(V<|6gx^w_<+7 z{EGQS^9}Q}=BLb0m>)MkX1;EI$b8Lw#eAP>neR65nHh80ylFmdUNtY8XU#El&^&C0 z%vQ73^q5NU8^NyxzZiTY_}Sp6f}aR}JovHT>%k8NUkknxd|%KCzB_m?mHI&d{`F>p3878nd14uk@&f!cs4pmcnr<0~Cs?0BQ&vmKx6_(aFY zJ3iL&ddG)4Uh8UT zLH}WY$lvO(^?UqE`#0LZ(*DKvH`+hj{;Bp)w12$)W9_fEf2jSn_E*~9*KPqYKbQa$ zU;<2l2`~XBzyz286JP>NfC(@GCcp%k025#WOn?b60VeRzgn(NULigyeJL$i6(hoc7 zuQ}A!N)Uv|ZExm%{gh-Nndc%jFYCFH07i#PMUPmJ5Kt%lO~*W*-39Z>5`Mioiyg8 zQ766Sq!A~*>7-#NeTS1i=cG5B^jRl;#!0U`>C;Yn%}Jkf(nTj-aMF1vopaJBo%E`c zUUAY{C!KN9%T9X9NuO}iX(zqtq!*m@ypv8j>7jHdPI}x)k2&e6lOA=_5hopX(jl4Z`k<2@anb=N?RU~XnQD5kllC}i zx04=r(k>@Gx>ZC1B+U%rF zPTJ_C4Nh9`q;*bO>!dX@RrP8ot#Z;zC#`T&pObo>)Z?UXCpDbZ<)pfkYECMgRKEYO zg#MXKHrz}mzyz286JP>NfC(@GCcp%k025#WOn?b60Vco%m;e)C0!)AjFaajO1egF5 zU;_U{3DEcd`ThTYqMgglVggKn2`~XBzyz286JP>NfC(@GCcp%k025#WOn?b60Vco% zm;e)C0!)AjFoAzI1YBsTYJQ#Cd)WMX`+sbIvgI>P@2&qCrMxm=t zh*i3?UaP4Y+;~cewFHFRD)=>(r22(;89ErL!jh_Wg;0UMeoEE)Q&RCERU4-K`t2H3 z8yO~Lmv9uOd}7*B<3%ANs8nBn9L-GA!DX~hmVYO7U!f~ms7kjUfM{0goQ7sOx|On3 z($}X{?Pf$O+ofs|sY<~kM0k~Qtq5@o2#p6_&^SsG*2gq8Pw5#zlOhACjW9nrEkqJm zgjZ9k@PnH+D5J0t5h6vpPSt2?4C02eQ<^GuJ`BY|xUydTjD)8bT$*}^a8uDZ}5tIwy!hzZ*q^a8FxuwJ+; zn{Pa*K?TxoaX}8=Hkb#7mcDMc(pP9-*N}%yvk6r5WNz;|t*OgM2L|nKy(~wg@xY6W zFW`Ei+b&SJ?u@AlA@0lm*{^C#5vepN#bqy6qp?}C*#gvFl?LCdYPZAGfOVLDoN6ir zrNr8P@*ss_6j_Ew#)B3dC7{2rkOddG6l&aqP|=j%p=!%C28}_ogE%@`yu%q)g;P#1 z-mncs;8zLrl=VR?OfMlFSOLR(NrF59{LsUA(2NSnuJs3x+ud7u4A7+onqe1)9sN!hy?4|$28ADUCgub(@iYInjU_kjY<@6*9P+cOvs z+)zULvBCvaOGaQb`U8w4aHuXcI-@#99aTU_QD;6FfDCj%rLV6rN(zu7570|At_pC| z3279lDG4{CKY$u(vWmhmx|($}u;rYWWaNxrHfBeVKH45)GlIO`@FC zQb&WNVPW>9N%du?VRgky&&iBNVHFj?uEewUP!mUF#R4N)b}}v}`Tt8*7)z;V3s&+Rb4FM}dDFePg4(!JI z1RUR*T#&se?NzuWJh1|6AeXj&2@s{-B>@j*D+~4ZxS?fbW7O?)7bp4^IiWod?MRsp zRl6So4E@0Yd)9)-LBp7ylvFM-Mu-A2^n0 z9*++>d>&7=&s%{j9*^5w?eqzP2u8_j zUnNzKTg8g1WhQy%tMYoPNI&GH>LEh@MYYvbB?{U%Wg|$tN@S$mXaz~E@K#AxDX+XC zRiYe~DC((z1iJ2l;&^%>*NuAc4_RbQq&iiN26?OMaEybdOUgt6I|yg*fz1TA6yM(7 zX=%YR5v9qgvI*F!T-Kq6K{v z2UyxIhmpc-tmct6V;PRoW(cy^m}TgP(Dm8dxPY|*7Qu#qVNk|ytaX=30!@P}NKewJ zGQ<(-FbomG(lkODvB*v)kYd3QhshcO4jBX75YinOV$znkAN5i8Cs3XgYD9G;ehiDD zp<_6x6k==%Dck{8LWVG~{)tlwxXD#g0Mq<3)|3WKa#-;iVgO5&WDW;du3>~5;v5zN z7@vl~RDgkC2n_ukRR#yUB6Xsr6y2DHy+@E@-6FAgF(YGpV~8#qiiS9Yd~`ZNuE!7! zP6afDQq8nfp^7hImc%Z>5E$H8{}=-6De4*Q3sQ7{5c3Z9I)=a|PRhefeiJjFA$pz0V(}#>xxiMOh$YVOE|F-qhA?17$1XB#*s)7*w(ZzO<_9}=(MCLvq|LTt7g-IEyF?G!v5N*1CO#LDCp&f#LD;d2 z`i$(@MJq1Yj7v0;4Y&lRS;__zfE~NUNmN4`9k%V)<=C&wv0qoweq93d2<+D-j>CRo zw=NNc-NIg7)X8M8EQ1Xl5+Qv!Z7fffbI#lxBFpr$yFSOa4Zc^C-fPJrPpV2 zxKk@@ieetuMO;GFX`zy`biLwn+Rop_QGlwY92yxL4vYh&65SI^awDvhVWUfRaZ`wU zc?fQjf)msnvN7}t&?0XFsemEL!ZWZFYQ{dPE7_7RhS6~`q)-^Pq?3`Qs7_Jk)C6xC zzlTDNvhDdSZcUT@P#*XbI5u5i9hwRut)x+)lUG@#e7CSb50eJ)cp1tMhX#+Ud+MaN zS!`u=5rcf7p&dC)ODC_H3qvzcy{s?|p!C84HXb?+*F2Uuy58ST!%nBw9hJp`7_)*d zp0Jl-x@W)afh_f1lonDD>@j7l@8wB>W~sn^n4&J)^-8Q2FsD~wwLw$9k4`Yb!^Y$v zoZ^_A8=X=nxWP(KWo7#>C_Q-H&za2eiXmP%EN_9Ae$X+zLOZgb)dwRO(VD<`N&uWD zJhZacgia$ofdfo$DRhTMo@`BK#Yn=+3x9Pik}zBX|F^RL{6^h))lzJ)`+M6hr_vuB zgSGz$So>1a5_$mZTQpqIakj4RC=ArvHq+JPt zH985Rp2O+^T`E|grE`$#fwr@ zpanh{!%}#Hq1pf~8|RX-n(;vVLcBwm3L`tgH~eosqJ zD?FwYwIX##_7qlNf}9;T!-A#+!h=_b0MF=zsuu&F@HN`{&jT+s)w{8KBxoQIVh+e*ODSj)$x%62jnul8#vqZy zH$*iPwKmE%F14w?(>|8j*+I}10q0vMhcx=8I!0xr6do6c$3f+tNK zSchX&SIEV>`FW-N*v1&bI{8muI!-`ZITOgoH(}&5cG_=XoRoU5e0h2gPUDZvHj$@dqj-{()>9BBBU`~aF zt29AS#&+ATRdJB)LM7`YyH@okNFb|LX}Li!=Bc#6BWqUm0Pc|utJHs3B&d2bu8`rX z;(%?qs$;mSZ4#>JBg0koATp+5kE(5ts;H2LsOljof>&TvQEwZbN&_CYrAphnj})c> zD^hhoM35D!;*hi=m6lDgB9(S0d03BXTaT)@K_-k@6}2`vy%j}QVKA!Rf}^$23hrV3 z0$-@=9k@c~p^66EJXA4E3sz|pv}zy|@z5@hHezyJN}Dv?mj+<(1kvw}wd!dkP5T`i zP?RqF{T%xKzsLMT#r&rEHSNfC(@GCcp%~`veSKtyDciCis#ho#C^?l;9(nl;D$j zl;9Irl;G1bx)(_mH1(LjrI_C^zifWN{4eI8nSW&d9xm{M2`~XBzyz286JP>NfC(@G zCcp%k025#WOn?b60Vco%m;e)C0!)AjFaajO1egF5c&iDxEsw6$s_Ul5jbE$7(SwNF zz^_^<)#@d~z^@JA;FQb2@5kWadEKxyI&fNfC(@GCcp%k025#WOn?czjR;g| ze)VoVn@glqI$}NJ{r|6%_J_=`xArvp>wl{HPb&*=qvo)xOn?b60Vco%m;e)C0!)Aj zd{+pJ`*h_W807gqHN_y$7J`$he<=njY%d~tQm|f4c(S(-EM}+9B!bsQO4RGq zrA+*&8bY@r^t6yl7^*v&xRs4$sgiw~9iX{d)l;hogh;WgDZHM6=y^salFP~J8-XdwKoHye*7vhk=ya7c6ISF;pTS?}-)7vc>{y_($K@M<1Gpu;lAHDcoB^3)KY zuEn#Yc(bmhWq{`vP0uD$6rQ?Ob>$;>5FU?WB75W6l|%}xs1zdmEmO-Y1ft_1h%<$! zqlnaA-8*zFoXa}`4(mPy*-o$ErVL!&tr~ZsW}5126Iwb!vT9ZLT68&*4O534)ZK1` zB(JJ)``s0lRh0n*6R)Cpn>?>I2`bzOA((~SMMTe zAp4_`%f>CRLWfcaV=uKX5+(bf@WX~(#&CRiH`8_@__q#orx3)As)4e0 zh~R1R@1VwBEJaPhK*>Ack<~>HlDkWA+Vaytme_l@yH6bb_&58scAk8#dtas zPlf5~(cK4vOH9@viX$Q+s|c`-7@PvJIq@5Wq9O&so6SKqQ)(naEv89gB@GuB#(_?N zaMUC`>1ZUml#SEbkm^~9+zaOtFVfi&jGu^%uG}m%8b}BP?b0%cw}=qYsyDNmTMplf zMDO5qR5h~kmGoUIW@@@4Af)Luj&NPy;7v;t>X-_uXWE@anvC|wadFX=n#H;W6&W?xIO3)!dsOcpo@lR7~?;jNFgq8 zA{Hi7CD!Ab%N~*luni=zFkI{s^@&)Vvg9$QlljE0`!as`S-qmN-dlmlq8O!3bv}=; zrqb`L^mz|?ssp%Mhe7PCu15UHno6IKqEC8#jSU#s2p?QiT~q0Gw^jHWzWVJ(uwK7f zj9~rVgP0eNw+*lEN-GtKt^Lz(&|(inJ`8 z(S$h9s`qvrPFT859g#{a6EVc5&LA2z1p?O5shC%is|c5?BYdrQB@xZ0bLk}t9$f`1 zMssik8%c*_ag455JQYpADeL4p)3*?v7k(e%rd1(BJR_^^^ATxU)usL5cRgVP)F#U4 z6Rt#Vm7+kyi~@CybUq>i&fga=hI0PlSvb;HT!X+AMquVf zm!8fbsDI(KJC1aP?xp!( zdRkn4yvkSNZ#D5maZ#X&F0FDkMZ#?%$UGrja(#d=M};(Z?T%%FhVb7NF(?A%sUuM2 zq(;%*H8>}C{MPF2+wl4tynb~emx<(~%ZN6MAh6VwDOIHezqWcWvXYUlJFNN8vf{!* z6OS40+-lUJavDnxXPKeF@f!$*9YfGuO^mDRZ6q+kT^1gixTL!`D;`t5%ZXGjA4wtR zxF(KaHL`>hs}TyrZD^hn04FqKBjZtW#~Nbx!W|-9w^kE#8c6d3Isz9jkc-BWY;#4U zVII|`bJZxYbq<>9mAdMt#SM95P4ucpI-9^Ggya3HJCj_+G1br~baXYz3t+iZtfHT0 zT1~WQt|IXP^54=|YvOckT}Mw6mLqB)2bW8o6qW{@8f;fXN^XqYPP#gZlts(OAoLfW=zvSaJ)oIX*L zjiho(4FAn8IImIgc}=uq;UsIU-*D2J=-gZoX^1AQEyMY0!mnvGJ8L2+e2LV^@J9Ea z(ly%-4#D!4k)cDlaTBrAso1Cvk6TsIR8<|UcGuKa)K@mwd8!)Sm9mF|uztfs0f z-R>rLmCsvMS>>z5vdiN;P*>-3dr;Edq2e7vg)3i{B zS!%QzM>u09{{B%i6150-gjh z*jX?k1 z4o~PwrQ`Ra@eJkb#xfnFkgC|HdSH6CpAq7a=EiCZa}^Ql6K<^ZvY2&o(u2W~P27bc z;bec&{)IT;k)hCgbt4ZKB-QW7BybCKkP#uuvoe~wM{mL`6n1NOOg}2UIxqBe(=km zh6JI;RoAUZ4)72kv@I zbHYdFu^M$U+!Wpfra^kEj^daJZEjfn_;7VB`tmRAjXRwWc+SCX=kLB0RTC`@pTaX@O;H_%zL5h_y~r6@{YLlX0xIRE%`T5>6+5(g5a;j1f)4M2>?&VK}q7D$Z{XbyeK<$SR)1&O;IosRCO{1hv-; z#{sBfP8iM8hC24VX+`=DMWGky9hQ_QGgs zl^TBoK4&R{<%}vW3m0}>vVYE~vWv0CqKR1*2g#MGdX-BP32FmgEuvnj)Fj(F?Cxd% z9&&t~Dq4jPFQ3r*i}v!Wh&oPA#a2jqHWd?rwiY2_^;@Q8RfKI1r(ywHxe_L9*f^!I z@>9jqrllRLQnmYWQm$c#^Haq#UJdb8RCs-OxII;FZ&g)YEnWzLKdgc;tgi7j)O)dB z_rgV1A!{YPWF@lUNfC(@GCcp%k025#WOn?b6f$u&6Lsu(RkC2Jm zP7FK22iWKyJ||2GJ_AY#KJ2A?kyJrbkNI1Q`3>{S<`>NWV*Z)=N9OP00za4l6JP>N zfC(@GCcp%k025#WOn?b60Vco%m;e)C0!)AjFaajO1egF5U;<2l2{3`TntHl3Y5M-Z zYW|Ia|NLMAOn?b60Vco%m;e)C0!)AjFaajO1egF5U;<2l2`~XBzyz286JP>NfC(@G zCcp&VMg%G}zly*!6jVf~@BhD~o>cIy|M#1-W^3?s!5<3F2ipU01b!lLGqA7YFFSs& z;~gEQ|I_|o^1t9e)&Aewf3-c+KHT=TwqIzw)7IPirPg0=4Yz!`NS5-^IFYP&1m&Et3O)(T(wd4dsRPJ^;A`JZ;FqJ7ZGRdZ`F^fFQVE1fX8X;r4|L@Wz++HFCs>%_-9LN(%t$y z{9-YKFpTkqWdw$z&@+e)kbR5Ke7YGORBGVDO^@K zoeIlbacuXOR+>?Ab-LH8OEX$JvV;`!w>)~a2)tGogzJt3pfj03~m)WiZLDBn_* z2IX6(@-2#0bHd8o5d38zBhK~vup1yUptyVLE%v*?C>Z~ z`<1UM-)vC6x=;Bkg-?0h%G&j}b=qA}+6gas?O-^vdeoT9htz)M&C|-8WAZQEU9?`X zl{j*ytN{YS!g3;a9wAz7kk=?k6aFAFRHz3$LdA$x%A3lU{mPrY%9{X_KVfBTx&HEv z+iI4|8_Jg&lsDRxHz=^xxRrJq)xD)r_zaR;zvH!|YF)lMJ~uuSa+(!Te&dMp8$I%` zP8r6n)Wc+~$%swsTZVrtTt^QlR@>tA?82rRA01FWdRU>q zg6N4$Hq}@>AB56rlRc zqrH}jT}Ak)a!g-UK5Qzlb}Fx;hcd^kxGkrtyot{vR*~a5l~vn z$yhHH8l3Db=_9wNZZchbM6KitT9}~gE+bN0MHipEqE4M7wGmA*3-q-loRpf zo~_a5y5~Z3hvz8cIqRAfrjOQ@5r&VliiUDfRH0oxa8hWo7(U#LaR94PjJ+4jfpRS)ar>W0ptpzDd96*RN`>|=abB4G8Vbe~( zmusOkA?>U-6*}8<76KC!*1S#X_->@!H1u}#K2V)Lcv?H%a+*frNo$Uzc-2u1r|m;S z(aH%pwdg+8ehOt{ruC#$!BZv2q6ZJVO;woJm1CjDLQ~dNnM1uGb5OfCTdj0zlhC&%Y5g=elzPSqZ+_11RQ#@1HWhOH}7ju+Fe^VnRQ&X!EO zhk6Ejdhw(wcgC8vE5B4`*q`@{sT4&RK97)mlbOW)YKFq>O)rfXLk~L+py!y;bFe3l zyr-=hTZzuV7A2&czvRs!z+D`Xd@0y{ER9&4@sQebu=`*TPeC_Ftjl)!ww5jBrJqK5 z8JU_KyjpRv@n8*}bZOYSWVfQFZD$Xm74uZBEF}&eavk&@q^izYPuPWyx9s#V=Q}2D zhoL6IE*3+ABEWkpFNFjFi_j*j{Wrj+JcPNL_o~U>!OuI%S=;Wp^Ig zJ`xQ5jCVD^G<3`fZogw5^;eBn_25aF&RJ({IbGf5bX?3YjnPI2>CmwfZJ_ESDlLv`mD$H$ZnI<>vTFeZk>|Z)m|x`)GSf5Q=40yot>HsFD^`9o)2FPBaEe2 zvuLntHJx)ueUYmQDRwM)pHH(${SG{66Z(0qd9)qwO49Xeae!0&$~} zamCZ%p~EBAs9nI^(eX|J3@1#(7`{fM4B_5y9komNcPjy7O{52wUG*+H>aj-b{7t*% zr_6edPRVg=*v`Dut2pZH@G4q|M%TKnAz4y|THBlil@zP_L=ugtr}VTnXy-k?!yfVN zqE0P5esw-PJN5KJc>em#)MWUn@ym--)xFi;>OgfklF6O1j@ac-?Nxr_%Iw12mCLq- zs=g|3m04v=7sXIVq2M zFL)orQ>Qy;tsbZF0mogb#oZba)57op@?=f6VH(MKqNak;%sI zUYwqtUl^aAmf*6nkp*_|@qtbp3UBYfo>rr^&O1anx%avIXoMIuNj7qei_!R(jOx zv~%~%?kS!2WbUma0prc@qDx1tgR+>mb3{`yxPXljtu<~G_1!L@TRH0s`i6XQ{80MQ*`=h6Hkz++RAp#KG^@8# zX3WLvljWG4agrcEk4;~LKd6H2-+v0lz$c}jK=m+Hs^FOZ&G@PKXnZVA0UupT$oyi? zfkj<@s#2Bj*6-hc5>>?!4%HMXMUs($NFqX^4ll)JN}=IO$t!(<@vx`mIaoM25*)-& zoPix)iajuaR)=5*`p@^D#!no89a@UY1aJVR{_0B5>IAHRZ~qSb#Oc?rrHCp()6PcJ zKysQqQw;a7?@!=Ie&?6MA|4+et>jySp}Py`_UBhSQZ)OI2HX1w`l*ZumqIeVMAM!b zJRs6*ls%Y%XuuA11xWI6U}-=`k3Ar|oPpI5n%NiWv->*xNc6C8DJVf0ZraOI<{R!S zt^)sJBE=52E7H~8HPCe*+;m~7U&f7`sQ^*g8ZsX6xD+$VbtG{gxU-(4=s>RXVCQc9 zDDZ)$fUL@3%fdjO<7zBU=iyFY=RhZ=KDyLLu|38hMuPfP2rk*0buoPj%h&6pSUZ+_ z6#z}+2Vym@&pjbJ6&)Q}>JgDztT-tW2A$c=Y`$>M>b8h@bg5egZyuvma4HGVS$T|d zjfnB_;8K@N31bv6uN_t!QmP-L_!_Z$Il9y-E89HpBD%AdJ$3Hd)vH+3UO#i; zs9l3aZBi_14=r_wBs|Ui-r(Mr-9E8v`>qlEM7KY*O=CS@OuFAZ2WoRAW!yxGk_Twvqbz?j>8sgoLqm%(_nQ8w&WMKI-Comn>C! z9QVq<%6KJ8-`K^yYw@Z2n3sC^`6aiA>S^xb26uTLs)NU^qt<@>DDa*omn_O)>!Q^4 zQG;1wyUU7N)Hz0%xQJ;}=dM3|#>M$vbrCJUh5sqmnI%)^cfF~*Al6`Im%nu?lgkt! z9iB+d&CE|_bL!Z&kj`O#cWo10Z_5%BX|>p$P+I8o`MDBA_~{w!{E6JLWXR011VW3| z_SM?VoxOjAN=yUjuHIZ*;(tlpPn=7H;{PfB&*JZjKM{||{wVf~v6o`UV%^biM?V-% zMU#==hYr9@WHkII;V*@6g?EPjD)hC`YoQY%Ht?$hivzoZe-r$b;H$xdL8Jd)_P^GD ztltQHJ@6WI0RFh|=lk+~TYLYX-mfAn{ihMo2xtT}0vZ90fJQ(gpb^jrXaqC@8Uc-f zMnEH=5%>{B;PA4LrF+a~6;9amG}^kQ*h6TgI!c`8+4X0;&aOL4vYir+0^zl_-6M_>>1E($nJNMJ4`?5L*$uF&pd)3y*aQf zh3U9*Q;A(ud-vnbS8mK_Hb2w(Oz0VL;bvJ%(~VYnf3a2G!%yyeawmS2_myQSNH?}N zX>|g5_v6rKdC1!q=94F%3_dydBs5t{hnJ;{-Ds9>*!(k|?p7Bj)?M3mZ5w`4s=2Jh z>?aMHX@RL%_<%*cSzD1y*AWG0ez_ISi&MRB+69SF{%S!Ox3RTpgxkcAq z9=*H;KYDXwS<2s`(BHB}S61jZFCV=YxxD5wsq^n!mNI#xS)xg8Gu$ZTFi%HLcby(M zO=6C{%Tfw&G>bD;+&VFbITjx48Vin*pyTkelEJsa{u8Z5(rb_HJhl}-)p$x8zd{17 z8gK2fB6`{H_oK(9b;@32uvG(xD;X(Y! z@3CbiOK%oQ3(!^-P??919P2%tJWK^Vw5;UngDrcti!{X=uX34tgM010eS1k8?BKGJ ztK&9X)0pxxkE?D>k|OEguAyB~{OHx;Whq`ahFfRf@+zCTYvV3^S6~-iWZSna<>@PM zACBYX{0cy?a>-(+ylgYbyIkJTB71z(_6!@_{Yuw#4uw&m+r9j=pKd zQn$E)W^UQA#op4th3=p2S*}RaTc=Qe&CA@>y$QNGo9L3)&Sfb!Hv+9=*4&;khdPGr zq4putW;(bm|^tg6v+F(sd)$y889w#fD28><#`6<4_~qvn++_ z#)c+!1L`#!NQu9qv;)nDSSZ#W>yMG{{_bTZM_<=6X05glB1XHSX4D@=ywStUQhMHU zetD0_HJu?%?)h9XJC(~!B&A+Bv@AE_$LQt3Whp*4g2T-WiUjUtCrZ=x@x23)fgpbL zYTL4up0hxUf-k$7Mfao(<{B6dxd!Y3Vu3@;Qes|H}>MQQAeVa8 zcDGH$$a0&Mr#EYDtKfAyG~1S5lfj#H{8jMD`5bOmkN_T9{`9bQ>AsB8tW2n)px9D9 znD=p_4==qcMfOs?hI&{{wH^H!=*2*1zp5RRfObq=XvZ90dPOAQX?2PC<=XjMYDF0d zqj#&(yHWg0CGdB=*=GB;v?YGv+Y}y4h)6aBj5j*pd>wz#P+nS#$A8bZrI*|ccC~VFOlDWE8{=sMGm3Dy!EfeE z)1dr@rS8DtA!fRA#r#YLWZY$x;PkKnPF+eF2s6^+MlbDwWQa|cNGfc&GP84~dkEai z+_~}mL}mhX!muXO*_rtQC6;7vd07H<#0b)fqr7)V*Y46a#T(%wO`pNCRCX5=g>W0z zO!g+Mc~B;6OqQcGI}DzKF#=GX2Fp`u%W%)Z6p6^bkGWyQ1}CHNUu$@9;XYfyeJFZ- z#B`PB3ptQH>R~Qi`pp#J9k4tpac46%oxk&eefhNB<2?aau-u?-U!dO>)&$GNPH$06?BJBd5jbY*xq zo`(&$y!Q+`#7x*c1JPwz>S`h$)^p41u)O|uD`K_#{JxlFTYlSX`+S|g_D+w-<8AM> zEl+2c&(ky2w*R)nXy735k=Pxt%4qNMe^@cpd*f|w=9qh1YGQtF$C3+$a!+PwGPykH zjfO8hGm{^$u!M{4VP;`!9Itw~OWbe=xy6Gfb+H}XxDBV5_|L^$hWjS4LW84X5Pxq;klbJbR8G-%dX>MYh~AQv308JxY&Bu0ik!Tx=h=_bL+!c)8o< zsdhRS+wXKb7kfl@Iv2XOXaLT7$5ZJ4sci0cdL|3GFj$qaK4!W=r3mlMfCL-{YLNA0 zCo&YIgg!k}%HF(JqEa2@?Y-)?kbZ%2pM!aJsHVSN+&^_Z`r11xHaHx=Kzn=f-Yhy)C038og%nHkXg@+Ie+!Ml zS+}Vo>HZa^`Q+Jy?3c zq*9=a5mb)(9K$jf{KJ_WJU~cLKreG;i}UFjkRBfE?(6RFNAq;{_&U9Pe!Jc48?f6p zKH;5XhF%^bHlWk`% zG1-DlSe+>t<0DlB+{O|!n#oREn6%|QY_g-wt{CA#_&vtJI1YnheKL(`B{DNonx@u3 z%b4t(WoN<0FJ&@!@pRlYX5qAR4_PJ=BP|{e`@J>et;rs%EDB6EX!>xA8Hvk85cmXh ziS+?Kj+!*BuBK&z$<7$+72)3b9Jcuh8BdQr2=!op++-BxjaT%mFZOAfWnFfs58;}Q8gh;_^`rr>}D zS6FzM>@3Hv&tfq>MY-%XJkyn{`FPxo)yO1VtVVKh+@@Uvz&_J0&5z;DP8yC)=BGK% zWR}@Ep(;r80vZCIkJ1p0C0TVvV`3iVVsJGv60;&o%6;6hXmLZMvB@?WZYh`pz1Fbg zbvMdkgmE%)2P%f;P9=-YG}D?`rMScuaFAPQ{1#)i$<{G8Pqpae94$$(Y&Bw#Wv0aC zQ^9~FOaS{h%QBOl=j|lsg}|Onks{7X)?Q3`-^pW8oxn>~RKZlWnLjh)lMPnbR12 zO%^dtnw?D?>w8HEC#3_v^r&YOlCWfoq}!g2y0c5#fj{`P>sBi>GIg&x7*g9 za)t%03Q74sJcF@}NB&fJ}w$&P0!&S14`Fjz&heKp1uQh2}9?jREj z7*v!E0{HE6dJx0XfltGD3w%3V3?m6+{Vo{Zn*xzwCiF)@cnx8+BApx0;AzNkOT`;h zun$WYuqP%&yoec2Ph%`;TT)M|fHyk~yJAfhPs3QIqZd*ZYYdxoMQ9&_r$<)b@8wfouiPwErYGC@IPbgY=bJfiMvaf3qN z1uAwF)E!US4KS=2RBhMGZ2?_bm)y))Cvh{w;tYJxu(IXz)F&BMWu9F2#rX_be8A%t zgA>G{x=dH0MDq*7vPG=%8S6IM!IBQsGPX@JEyIf2g@HUh3mgNM(Lq;y>DsyCZpKDA zR{s`erEX70M^B&6i^W3+7}ej=-T|vk?Ut_{HkUl$RBxxZ-O~+wE`Qh--tF)y#ARak zB0hb0&l`$AKRndd#y>~=i7hFOdk}^)PIjC?4I8eT=_0^0*hc2cOy;r4GidQoOl+Wg z^AgrSp!PD3R=WjilfkhNrMF=h7t2b6T`)zu9VRW^V3n9zc^c-?$OgOUF({`eOp}=2 zU?beBaHheY!YUBV0J$OFT&nK^278Wqi9P0pU=aasFi#fKAerhe;_+!`MQ^ZuSh>*- z)!;jA+RYm5q~V%O&xrDDW;{!)e1mOqwyy?1?rP{f2Agf_JlK#royTAkScPMsgch4Z zV%JSiVLXEkX(4?FkIw@lT80}KhyAoM7HT?}@Co@0_hCZ`%X6x=F%e;$#X8)ejfw0z zpbuC**n7-mZf9m>Kw2rle-6kJL{Wwnk%TfCbVvy;RS)LT?P$#FJMb=!fN4)d77gMOE2wNW|g8kp4G*f^C$ zsI#W%Ii1WS7emB ztN2WTZ8vuJn3N6nh#KPz7*6xz_=MJ9w3j#7xEkaPY=y+gW?&*P@1!ZQp1Nt-U@1Am z8CbyjW>W+kHcmM#zzjB7<+R;?!@QTF!u2){eg>PyX^6MI-Q&fdW&16U-{0MZgAj~| zevE}3o!;Ia57z4*43V%f;PqjY^dT6IOKiX2!fwmw^I_}d4BZO^P z9vD@inZ3v7!Eo8vW#r)maKt9h?%ami)?>f(DN^@i_Sv@XApX&R8Uc-fMnEH=5zq)| z1paOaeBk6;Y6JFZUt62aX#<9TP3FCcEe%tfzH+VIPb z5j=Ksy8w|ymiEJV+{Iv28FBhK4jgSM3AC}|wu||33Nt~0HZpr2dpD#pPLD}1$2#;L zkp~&SgKY_7?lml0nbKy#W_KBuj5hO{42Li_Hs@laY_olGH)!L`kk98Pig@=F9ctJ( z!L^;mt?e%R-Qq;S=9j&+AGBeJ7bokq4z$@bv?;UMJ=0fvC5uXPlyUm(0AB8QGaPu> z>?M=a`6;MpWvrKR08o^Le1&J?H)3Yj&mxTJuedF-GCUPEP zmQtXlJbD^8knlUeE89doM0p2}REi|~PnHcN}z@-igMREC0EHYP9kn!;lM zyM3{Nv9Yr<>7d6JCx27ZAch(67Hq`EaP^5Me=lL;Fn|I|}Ha7qW`! z*4mYPMobd|E(AF0Hr2~Mpb1$Xm8ZxdZWX!ma)8cSZ5HA#I`9`5cW~@hY_`d?ZfA>G zfiW+yqs^?1<1m-lAA>wr*<9HCYLByxwOPqtgnV^eIxqAU{V!?A|i)8c(l@_5+U+rp2F&8v zhiiD)P&r+*S=MB;b13hK!RZJY)Ml5*`7##8Jk)`mHr!)st8GKX@1oOSQ2>kcOj>k= zdDB8m3&}e|KVo{-sk-Rmyu91A zB%|L34)ZihLR1?N!XY~Sb8NiR86eU)MPg0>&fE~8sF4JZfR$pNl1GFHhof(7SZx;J zzPb%QUY)2vNQci6vAMS)7;sJJ=Vz#QzrZ1SwAotP*)2L0uvrv`@i-R)HG`4kraW6lq>x99p}ah7 zdWAFvhX!)g-^*O$R0C0F)rqIgw$d>HPD{NUH{kH*EX3~Qp2P75zMcc>yewQd9URy= zkT*#JZ9`%!4@Yf?e=T)kh6)|Vfl-#`vkT~(^vO4nPgpK+Hl@$Y+YEGRd1i_yuRL0? zafl+07Hm3$CK-Uuc3}DuJ@_JuGJ(S$n_V*HR5^+PYjg)v+Y@zHL_E>J!p&oMP*x8r1u65lB#3O3utytAp?c(vbdhsbh59K+e{PIbV! zj*gGHL9wjQFzzA|sAbSx;Wm79Psuq0Ms^TeciX;hh#;)aP8?}iBp9~3JXX8a3n8-A z-i6bT4oH=~UF|s2uyMZY_1YbM{(uh>ORL@Efd}+OywD2qSw1L^wEKMR?Y<5h5{nM# zjaWD(#<{SsquuN2^mlal+ih=`4asyzcSl!;$MQxY_VC)hJvM~bNXhEL*@(Bp)7|cc zZA5r@@PPE7cY-4puix7lfPc3SXDrqy(jQlAnSTbUi2pad+fpRyr@4R0qBafCcOaTR z4}o_J0g{PYwCS_(-lcEch*qrMLRXRHdtIoAo z7dq|)-Ojx=$I$SYsw{{=w>bync$mgiotc3~jHnbAsKgp{T%8}McHWJpBTeHLj=cpV z-~ihtKGgxQ7kCd&P-R9?O>>%jp_hm6q*Gv!RCacjKGBH}w@|TDvrtV#`4%Fs4snO5 zE*4aJ@F5zc`zZ6?gJKNLmJ)+=2FGkkrP#wd#oDbtIgi~BHpZ78X--H@%w<#bO&RLq z$SZ1vXe@KPx zhFg9@2lOquVzk&L7M3!i6n)VG60O>~j0zRDgGyfs0te&@0nV&i!r*OOEjTUqxarBD zn@P<(qy%A$S1uYsN3e)1%~5Ht$c3}Thx!ap zc48b-_yRT}2(t;A4^^EQcsxf7YKs&MsQ_3|Ptev6>l?h=<0vKIVVwu-E4X_}5fAP@ zS~**oGsRd6Wd(ZbFiaE~M3P0p6j)lx&lKHsK2wBwP=w$Mv4~6R|DaB5SZ59NLZJ-4 zUL`ZK*a0Q+u-H|%tI`9@kG8v{8XCev$p*5MD$++?3REn2UAE#rF|0mM3m=PJGhI_+ zEsMH9c1Zn<4ZFAyv%)J{nQbke?lgUJN){4SQTta+&t#4a9*~F(!LOU`#g_!%sKS?D zblDIW&Q~7O&K3coA?8Ihm*z!MCM4Nutni>}hgY4Z{}z2&3Y;oFO$EP&HmJ@(r9_0r zvXR6j*w-R(S9K?Wr@PE{RGKVzc?1IDqS(3O9fU4o*kT8x3XT=dBA{v(3vU=>0%O$?bM1?v)TX_rn51yeNl?C}B z7NQnggH9{AeF$_AhYF-nMaxY*KFQ_vWun*9qR)aj+7aTxa|3O7EVkC!@W8LvR*aB^Z;J&K1LKfc`q)JbM z$3*-srUwdBrAqfJU*b?zgXoVU4O>>G;EaH?Skj9qk9oxA8UdcN+;YaI&^K)?I^iZv zOoq@WK#zw~U|OPKM@*kEphLSP z3%ewaxs0%Rtjlew54}-_<8FhR_6}ncw2}y5vqW6iZ~E)Sp#Yh2*JOTNcD5vka<@2o zK^j}@w^8tUiPL>%OaV2+J{WNhda-zWqq4cO_{{;@U0H0Ct7c7f0v#|n1=Rx@%&{Xi znc(h`eiwUg*P-ByHy^tMu|^p{8rG zN4QzQX3Sy}mPNCI7}!V#TYes7(!g32?pT(Y+yFH7goXsBIUz3|Xi%*6vH&$$Pf(Y+SWi%F@@4j8LbS^dR0>PI(qAR$| zRxfS)YFp|GcXX4G?n#*2$99wsGeuX-yTW&7$MZAL=6gvjodsPDu#n)kvx!9ft6b_@ zgT(EI+=Nr$wVYSxFKi#BdXU94QPxvTK~1|b1LfJwgzONg;6gWasl=ueDZxm}T>yl; zBs6+LSNx?+fi_5|8n&MZI_c`JEUK!zU?dmhT~XU(S$%e=rxV*i>_Kh2s}s`lUfa`- zKfk}LqqD=??d$gWJ4pE69$FLdbbI?_$*|q)^?BCBvCWM3w);DKdjdTjy&ent$6jwo z&=39Ru5N5u{WdnemS>%>-wPRiS9_=Bv)g^JjnE0rramN#3l5O%BUi}pEq^EE`Pi!B zW<*B^w4r=mogMA|-mVUxzrD|HN8k>R*Kb40Z+rVKY?*BvY4r3VvcJ<~chL6O)79(k z^;#&nF!%XyTH7H11NStPzNW|%lICa*!m zOpBtFZN^gbj>D@X08JzrLv94~;vpO~!UwW%gCZ~9kU+W1 zSJRB;sH|g9>c*1Iy}dAIicRgp5DJ!r&q4=Q_aZr$Xt*xX((Exkn3hnGp@$b*nRcX!n}UUFwK_ z5keu_*TvCXqDixcnMndmcY8S{_d`7Oq{u}Lcaq90j` zHs1)Y050D0i=hrEqEkyQCQRI%f%_aVrA96Vnq5PEUQ@HVYPqfck!^D9yf-C|h+90|YJ5X6#(epBjJueu3lVRe(7G4Wr z{S4)V!xT!fYcq`?qsHT2izw`U6RQNPdkENh6!2FlR*O8(;u zCBzpA;jR!>Z~+P45e<3^dIq$U<{982Oa2I5>jRTsK@So`;VA$}d{72^Q6I%+Suww0 zgoL+yhZ^L%UT^@W{Xa@U#})3yFwhb6$HK50RTg@HJ#p}HWLg3acKIaEVujESDiDUK z{4wyTXh(2gejI_IcQ5kgi(rje!G$OxjkoUgWxpIF_9#XN<5LqYpqt_zHxS&797VDi zR^^n1UW?-K1vJGSr0(V~pqgTKe9XiRQ_ENG#sG!!SoD+-CJlUko+9j#(+_)6vN~Vf zhWb&%EkXh{M{KjWQ&!Zy*p7nG5S1`YoYvu08MP@SsVx?!WC5UxAlrl*0Li!){7(Zq zw2r$Yk`~qSmhY6KNC{lxUi5*$IM`911HT>vwpkh#7XzSfiQ>P8;-H``DcTXIR}zZ> zj21LEx)-|;v?vC3oT7=*eGzpM47`8~bH(HhDjvovf**$>Y_pg)QOy)pvy3SyNu>p;; z!!S!p8mJ*)HSSXx9!Eu}Y9|p-loHp9=@3l_IcQS7uPWAE$rOiT2^!{+#Qmm z5BflHGX>Zx``SX@Fd0uN1b7f$v6SczX91#U z^cjGMxW00a>^HQm>>J1FN@b#-6%*PEC><3hii;UZfWa5n$yo~{CIUbLFV}JlBZdiu z29zORp2T-LajXJ;1YB10(EWBKOP4mhM9V+W;t2;z2m$>99JoL&0r~_MbQNrIDIU@2 zU8l#wZF(d^moyMSzJvmVAP^EnJN^|fp%MY*1RUSsz=%9?UL`Za$rPT&lh^ODapFV; z!3h!mg+Kj^Y;mLns$IPlm7vo35RgKn7UBd9XG2sH3M*cSl28aAqS{gugZxk=DmMK? z#h|bvCn`E+jS6}E-Jn4P&bFN>!{CcD6$9HZIJLtcZt5|^cqmgb?-;C@hI#j8+){ZO zdya83W|9z7pPNyioyRj)?b~-JLEyU0?j(hPL=dJ)K)7f@KMn#*J7F3HqXu;JU=Rh% zaP&4eE@2W!xcMV!`U2TS!P)?eU^h;{!M|kH#2lZd6lfZpg}a}6l^dtYG+@9y43?&8 z3W!DaOcpK{3>mPj8+r!R!C|(Bx~-d?lKg6txWY1!SuRx>IHWdmR|6QR%Hg6Vfxbc>+|7&6pU;T#N|? z4-i-eL#4%yd$nc>GoPDnQkBKxOOy!fl*#SzZ#Rv-a4;)le*~*GV(fN9or^B&3sX2S z<^tk0ib6SHQXpeCu#`X@9|mzabvXRdztmlczww`i-U3I^^i2Ot?kAGMi1*~kke=XY z|Gp+`-WaHutXbNL{_(q#(S<<}?EYZbLozswk`RNFjT*(^WQ<1eG0exHelRA&`z0U4 z@ChnPk+BgcLmwf@$FKw=AcqAU;$zrE5qu1@Fp`g9JVfv@Stt>FEXn|Kj$v&bdQRNMkanG z!z#qDWbH-pDa?}yK7|bw!KX0pBKQ<0QY4?k>U^s9ZF&bE=j?3^YUQ{^D;V%kF%;gCW{#~u7 zym7i>J!QEMLuWfU3VSGScMOD4Fz8||aj(mV2T>``w}61wJoFg=Oa=ja#XF2}oHj{C zvV8)^z?lHIbn8%69a+D`*UdWsJb^{0Vqq0jR&Xh3XwJ-+8m~C$7yv(aj_uAtiai0atX%mwW0-tahP#hNFiZ6 zA!-dnIiUMR0buwBk-{9-9&F6%umXYU8b5l#1P>k)|KJfc4Q}*EfiMjEaN#uk>4&u% zbGBkMrX0rHf0S>NmMYMnvH%X&Luh4h;$}8_coqbP2UxQJfsqVCKM?3iSb5=Jjzto> zOYA?ktzw$uo#BdUiuddVxBn`*eR5`r@fg;(s5q=A0CBRCfcXpSU6^|Sit})v!wKOe z-2JpnAPWdM=Ark`3cg|tYjjG8T27c!D2k$CfCT?zWQL^!!Thj6flf}(s3s{@FFIy`X^x1{K^xNh~!p$vJqT?_nhYH67$mF7{+N%7JYOq?Lq= z^*oZYFG8!8R;!G!OM=TNQ5;H9gdoOXbW3K(lU*?ycBkP9HmBtR$|2SxFuFiZ0izf0 zM<{Dp7{WWuCo?#KVSLYeRlX<1NXzVmNDA5`2!-Id!43kAgH1T2DS_a@=@7tk4<<1@ zQ!2#87)2QMSz$loD!S1N-UosCK-B3O+ATBaNn?8o)C4T-7D_?5xd8DCaM4#O&nIXr%4mr%EM{O> z1Di3v9&-RxoA3scNRi@!YecDGT%#m%!I}Zn6C>V5?d-LHM07*OLx&L-6|>g}n4q$V zVCdf^G>4rOes_{oG@Spz3I=YHFf4BnSxj`yy@8tGo(O?03G8Fw-2skOIsRzVL34rp zU#!q+XJKJ0fbNN|3s-Iu;AtT=J=3xUKbktQ4o9zU7mM|k&1Nh<^M=xNe%F4iJw7M< z4s0}tu46E)SHaL^y@0w3UH%uWPnYCr!2#@S$bwe~Zd0-L;s~bE4 zq%-Jd&_5EKP8J-9!(o&|a5&6JPy-q~Ecsg8>8IQC;7;Oen0`PC#MRBnpBP z7~KDtj7L@>h_}d~2*V{QgO-!vB7;Ye1aT1z3*>_HX;1be`e0xehtPtdA?Sj47X9&NT>lavX9T7-mT2;wH2?}Mfg)LCIsOeiMejnwwSH@G33A&X7! z@&L51kvu5{l6g8vsGw^J<4Zy$W7~m?nNdMqEOph&e}Sl+rK-;_^6(l|pekUxqj6t#twtpzCI!#p7`Lt8-9 z0%i$B`tF4ud_^k_(GFhPvi2|lAtY$F{pk6_5Eenii`;RoL*M}U5+u6_{hE*x+3v(z zNObAe1#B1sG!z;du8<5v{~V!Sl@Pop3gj-Yg;oz0z+K*gxlhFe50E;Ncnp3I$2#G}V)2##95Cyg>zkt9?oUlp(f*o*pHFFq~grstU(g)L#!7p*6Kv*9{>yz;`cX=Zk6xRl9d!mz! zPr%QcCHzGRVJ3nu4IthNvNo^{L&^p4z18=!@U3Vw7y&>AP>0AIOw{Q5n)cfSN1b&^SuPfU;#6fxzVCZMcwAfwPK)km$z@1 ztO_ohruGSmmSbb0;iQH-$})|*iw=M$fYv#DA}B8va{&VbWx1JpvdC!)!f`Ua=q|^g zbOHSe1OgyXDYw~msU1%CQweyw6L%lZEXt3#?(*4d6^48TxKIp?97v&y6vMBK#?dNJ zVSwd5!@Qd$Gj@!m?UWL(j-g!gHW!)xGhY(j0Q#4t6>hUUhsl1W4Nf{z?G|*Upp)Sx z$vpmpVm36WNJknfOj1)?=v+ZRgPw(Wo&u1lo^-n50X1z4(diYw3LvHUctI)BduV+i zM^YRoABq6w4;d30TtXEak!4tt{R@&iy!U%R$JmX z6CX`v6Gsvq@qZuxYJ4GnHNGMCr?GFuJ{-%&4#dppZ%2PNnvWig`Xj#+`D)~TcGW;c<@hxzZ`s5@Op4V|9|fPwf>*! z&-EYg?+g69z{dm61mb;v*7s|DAMP9P8}0oY6hQxJ1T+E~0gZr0KqH_L&x6}A=7R+{STjejhlW#h2%#*q96xq}k*0fiNvn{xl zuc<_u$c)dd7CyX*@zVlpIEwb+@KT#XGlFCCT&{3ZNeW&5? zRs#QSzO|+VFu9By>ABKs1#cHTRH^l9p}m`U=)57n*Rfhyd?twAa~`4AB{%Z3op zW>-yqOHJ|_`cBAdux_l4fRA9Wn!}EocA)Q|td_yHy6~%~zkLf&pxx*b_YX59c*JtK9!{jys?Z~bBM@Wq&i$l!>K zB)&ac^?icVoG7aSdoZ=9I(L!-PMyhi^Y`sPNO7G(IDMw#cqJ)P)55JlwE2Ao7^P*R={+Vr+a1 zrvRH3tLD)u*zRE&IwD#Q-|$;K;dKHee8mMHfJ)=jjH|VSJ`^oZcAr7aruCcXwKKi#le@NU7C&R4$hEv1KByv0RZ zIq_D@CPwmqBk>>G@W1}k2xtT}0vZ90fJQ(gpb^jrXaqC@8Uc-fMnEH=5zq)|1T+E~ z0gZr0KqH_L&j+>rAB z-)T$y&JTA5bm=q#8Uc-fMnEH=5zq)|1T+E~0gZr0KqH_L&I6#FM^ zyYWF|ciZo@-B}Hpr_+P(;GT%_S{5HxxtqO9pWT?uP8C!5hSLj~ar&-|@)vy>hM(nw8S!}r%&!F~ITa{K>a2{c^pT4M!AN6}_23zeO?+gMw8B_CWEgcuwuW~Q71DuOzMDu3bhs(y~Mo=~B9}NaUAzaG5B`%IMsj$&cR>01Yqr z6IO83CVTNptLz(cR(U>g=JM5R$Ic}se>8+iUb}MU!lOy>*eC%F!72YC5gnH+fH&{}*th&sXU?a4xBHh(*l!Z&Tk3)xb3yqMgUZ232i z5C2V}wKK)!;E9dNa~qTAHzqG^OkUcUys|O*^v1!Kv696MeH08I??E(UaP9ERaVxlI zk5LW?IGmnX+i6;Q#_QXoWti2wOlM5?*?p~YS4`iKwDOHTF#o@QfAL4H;P&lCd8}25 z!1)cOflu^-CHV(-mU|J|eM7B^LrmXL9`cGjus{#pQ_8(zD>yuCm&aS=U7xMWu)d$v z2dV8QG;|l8PVrD3q=aD&-?CdfJRU;(tTD<)We%Fg7o$>>S#&KiZ&V+{s#J|LXf=e0 zK$A<=OH3=n<(>g6_;}1HpQz+Q8Kme-ZceSR5>S&0sLJcm3VBxEuMnNePzjP8s?B0V zt{;%a@I(+RlQp#~6E!2EuTtr1rrY9xDo4%Yq%m;aq*@$ONeo};cLxW9#%yiHC9)Kv z7<@nlWw1(JiV8}9zzVKgXOzL1n3hF0rFnv~hU{0V$^o|KMRG{uf@$w2&14r-0BhRetMtl;L&#=;&^p5ndPoB5ekA%pLz;v=oa#^*p?$r|6H zc*XpD0UxnbZ(5cUU&zXo9u#85(vBo)U6m|lb66}^>pomA^a@ZHH`P~SvReMhVqfc` zPZp`vvf8bRE6{KCVzpAzm)oW?g_;)1PfivyrDXGqnM`h~G~MEb^Lemhi%K8qv4W9^ zv2d-X(yGG6-WHXmilQQo*(Xn2yl~~}|A4q7)jD-TwlxK@`f(KUklw5>gjYLGm#=BidC|^uBbIH9;&Q0;hPFOoi0u{ zms2!EU26<49_tjd^*c9+!ty&X!2I7IT7Vm zo5ba@Q)8FME}R&1LW zO}MqJTH|)0?C!9FG0e3o(S~C9smu;=JL}r3@)oV+ph)b~G&wwAuv%e&8(s|f1=GLd zMNyD+v6vrE;lty(Vh!0*SDcy`tCXmr0c#@G1G-|9(X@JU?A#bm98Mg&a^l#@u@xo< zj76n%p@d{-=4W$FgHYu)ef+t_%M3m}y<$vyxk9{pep;z|a$Kxqn%ZWRHELv#g>61D zYQ7;SJDE0(169i3qE*C5@nAM-*(en6zkQpC(ujYm3z$}zHRb;Ig6thHv@U24>fpKgEx)cEA%dBC}`DL#UiJ9POVuCG{0<~wXh<*Hz#MZ3X`wM z$+C;Az{`K%K0rlEtP5Lttw;|FnaN@;?Wt>v+BXkk>W0$QMyv8ovZjcVnToVrZM6=mNkAO?H!sI(Lx|?6 zFnl3X%+K7$=Ay=T_#L(-c6!TGH9Z=07qM*7uB)>0d?*G~VVhX6^>(9DtVjntl~ZNKlUX`xDP?bitL4eSND_5O&E_ZOG0;^# zGk04IG3vmgZrG7kXg%hrcdLv{DpcbDHHTxB$$Vk9b<)n;RT6i?H72i~Q^*2GIFlLd z`NlKO=vX_1t`MYzO>xS}z1XxM9xk78i`~gWMzCU~H7l-F$BY%wXU#%Gj0wqa2JU_jKw4Mt3oNfbJle7(Op-whL;aj!G<^w zRnM9+u)bDJeNL@oWnpV&j!EeXl;=o;vPp%w68k)oS{8X-wUsBT`%neLX_AUj zSD>1Sx7BG27OT^FY}lIHeRwl>2aiRJHgOnRd~pV%)>L}Flo!uVUfWfsuCm_{%l@*% ztaF({2@)dknxa^J{TzQ(u{r-*(5Y@tXj#^}dR3R1RfZd7?%<KqH_L&-|mdxaV&@pR)gpebM?;>s71A{V8|9>t+63^Y_db*?&c5 z`tL^&f%3MKR&X3w5OIMJMjc>8f%Gv`@)lx6J(eDYchj{_8Sbo7oN2sG`mjk=u0^VB zla&jXBh`Fs?s^tZSivJGai=ITj7!vJQ;kvM`MELyaXx(T=QEw8rW-=6q_^qBW+_vS^KghRgP0D|jAUy15CpN#`DPl{1`TO%}&i zEK#e7b)}&zvx;@AtvH-NWCahQB<-zAQqicLs42n82bI8y(2z53gDXw_;q1YN4qB6T zW$dc)I`N?FYC335lu9j!^9LSKHx(gUTess6%FKz-SU2azaB9ChIJ(Zb<6v36XPP4Y z?3(MM$C|Qg2rF`_`VCS1;qvf4E0}^|hl`agT3mXBA|+I%TGNaCzNcEBFk`Dh=&m_-~Hbe8sdm%#nvEsf<-q;T6i+6mIz0J!rC6 zwaElak%&!^S}}d6>(u4o_zutP z0+-|FsndfiN!D>d#q;0;@~QGmLsr!n!*_RD!4nuSf)xf_>6T=T|AEFlYu?orlCrIN zS7$?FxHM`7kHKa}Z+*^9cMW9%zUtdgtKtzb<{ay`~s zG#6H1>}$-o`l=xgy*DHO8nswqus+}a8|Q>dHOB6SDYY8Ja6kcAVxp+1iP@az_3 zwxKRFF&|N{a3);UrG($E=Iojy$n2{APPZ>^#=;lbISb#~q^o_u)_3OvGjw`;Q;Y++wW|tJdBt{!b|mUmS7=H$@D&3#jb^R!nJXyMQn;`XD2k+Ag3poT=>s zYP*0~hrcK=OY(k!wqQFY)cfHxj>;_-x|M!~#6@pGH6x!&)@rAyrMnGtsF>X~y_5X9YKCe#DHb9I_)*yXV+7q4ADF^1RNZ^qd9 zV`naGp|>Wbck0}=t5?S^S94)Ob94mdO}5xUF1Exo+jUl*^TCDwio&%m4oo+C(R*5zq)|1T+E~0gZr0KqH_L z&x zDVHnPR4z0IYUTfLhhJ~H|DX6;;_bv25^v#a0PjyMC0N`(Et3vG2sb75irF>#?uJ-j01C z_Ezl8*!yEkvDagFV{@_T*p1keu}iVjv7@oQv2C%TSTZ&c>x$X2w&)L{-;aJT`rYVv zqTh;sGy3)D*P?GnzYu*Z`eyX~(WU6?(Yw*P=ydc(^vUR@=;`Ru=-%kI=uk8n9f)>C z?Py!%2a)ebz8Cp!eP{@VCO>41YcRweZ{FFNEI;zZrgicnJXO zKaGG!KqH_L&1rXeu95Wxo;`=XO;U&;eL$&jBr1~-xTf#_=lDIA?1Eh zxbNZbRqjs;cbUIOxj&)YcMJEOd`Y?Q67C{@r*hw++&6@~z`v^8Ur_GPEBDLF{i1R| zqufs`_ve)R3FUrVxgS&RN0s{#<^HsCe@eL@Q11Jc`#$9^D|b=2FY{M~dxzf@ZjqOS z`yww0_ZH79H>ccL<<1CqlE0wbta7K7J0;vSpHS|&a&HRvdH%9;UsCQp<=#>5ZRO4@ zx2W6~l{=^0TgshOZbrFj<=#+kO1Mw+=al=ba-R|IW&V_MpH%L3Fb#ZyGoO7>u=G>!P zTe+5U<-PMZ{1;_ z7DtCu+w<~v>aR=Iu3?Nx4%a=Vq=rQA;Ca^;%JWy%%z|KIKlwk7^5@u!JDNc>vj zONn-hg2|E>7{6#sbqC*$|yv+*b5N8+2}!MH2-XR&`5`&Y65 zA@=#$M`J$`do`Aer68L>7TX=$6!SwK|9jEjjQ;cJ=c6Brz80O2-ioH9*P{ocYoc9| zzm5D!8C;UkiRE_*U@4!KL8K!CWvMyc&EoxIZ`&Oa}Xc-v0mE|NZ_y=>LuWf8PIv z{x|#I*>xjV&CWbKGyf4zW4OKqwjv-3w=-Yo$T8I zfc2k7KqH_L&HS}j|=ug(ruhh_m8hWaRj;K8GQCOKXN_o76U$55;qHGhT zY5tQ{^4;|D*E_)Yd^(%sgOu*=%y=nZ;D_iFvh$_v3{`u3?ZYF~VDqKP?YmQG(`;^v zA8qp4Pg#_*rI`%>V3oW{>8m*98u}n5Uo1^z^T=zbc#)qkjA!^cj~QUa>`NK`VTYQa z%!?}~`n}pAJ8gNghMd8oWH*O1=qDTd4ju9X)&Sy)hd@eOUlP{uSe!j;X ztoP$T)9iJZ0Gcc0UjQm8o}hxNC*EKC;HL7;6*9N^FqJ=F$W8&!57p4)lu~}Kl+EYT zGn`T6*-WvRp33mg)js@e?Za&C!*uOKwD!SA`OIc!^M!l-^%}Z|=o#4yzEDGds)o+j z&@a@`@fx~=z6yK`Y*Oja7t*sC{^)x1EW7n+rgW*0namUlnTg}5_H-$SYN5wJk)D~) z@H1=8M;{RAY~~(6O~_>PV2liU>uy3)aKb3{u57MYO6SJWV9(S)J}F|Uw|t0VP2T}G z%sA9Vhia#~OlPQY{Jk~wPtZU$l>*abZ)a5h0b9-IO8hZmp~?9iUWo>Kt>4_rCgqr< zLQ>B-+1h&t0ahOxfj6}DH3rO4C+v7WS1ROZW-^5oPi{1?S6*zL%H%SIbSX2Dnwyyi zZ>0)qOwE;$viXR4(Ft0~_jWczourZ=f~|YFVBHaOpOe}|x=>0fo<|aEAMmb=nq=b6 zYkSB;kDf*T3-qcJsm*E=;#&Mb|RJ}6brWAjQ`tA61c4k7<0|}-0 zSE29HVUC&KiD&)ucXKEiNL*_14 z(`Bl2;M*&{d3wb+*H?UVxyxKf`8X5m`x-qK8a+~t9?v&=tf7(cT4{3csX~4h?)KgM zNVC^Iigs;o0&ID8I)f46%Z(l{Q;S^7NnWi?=3+^}pIiCW6V!39(6Ij=hgudChJF51 z4wWJHxRNP7j*TRetM$J-&|6JYEy}GA?x+j|uIxnZ5(_)oU;GbyWcdLb8xP_mWdh?l+ zXVCF3rOu%5@DU1yd7*~;`Liwju2HW?xU19IA~BjQ4gX?m?-z-=5c*7I%)d%_z(Ym* zX#L}D>b7`$Laq|dVVF5qn3|u(AX4PV9`tfAaXQ7E$s+JE;LAVW>@`nb^^4R%vsYiU zm#f)}(@Z*+OW&NyoSL7RIXRcTGCwz$FO<$q9=n~+&d>{ffku}Kr~Gv7!yL`AC$FEs zek^tE%2?{m`SW9!ubg>eEOqkO<+G{t7f+7yOT^8RRgUE*j+IL3@oDsgVjc+}e!#mu zG+Qbb`#=qycA0+0ANOL<@pfn%$^XBf_@l({CBB{bX5v>8zXUyiw-P^{cu!&hH~({q ziNw>m_kSvJFtG!-{^N6-M*Np?-~V&a68Io)``?e3;xFK? z{}b_Z@#DDZza_pt9>zU?ckHiYe~w%J|5xn)jQu+9`2W+`S7JYh8~*Q&y%Bo}_xmSf z&&Dp}cK_kn&e%rW?eCBIVhoxDe;WP6=sk;q!y;O~g=@L%En{vU;ZFZ^xjAN)%Am!O02R`{pG?+Gu2?}l^XiSX0m zOW{-DgW(RCh7xsqRLVp?hlh7Z8z7zV_p>KqK8TtvI3w=EF!O&9Ze&|kUE|d-3 z3_TgT5IPy!4}kTbMnEH=5zq)|1T+E~0gZr0KqH_L&Me)LIaJo6Vh-gO zRBmF9f5xFE1qF2v{O=?p7gXAZun?#Y46fZl}K}k(y`A$JWuc@VO zln|E-eJLR>7u1A^;Rw!2bBbpizfFS5lwRZ?bErFt8s{HzsG^`C80Y65>MT)(`CN{_ z>QDz9s#8$V!Qx{?k@gk;d58KLhq_A?>6h??L-jjUjb4@Tg8-es)1elLB3&!~A%_|l z6!fe(6I5;@!$0j%FF91mp*)frFYuppsCy1I;7~SEq_f38=TJZEP@iz9S%EsFgLY#9L@w@%K8^PY{Iy+~LnU)HOww_!k}OMMY)# zRYgtnk2=(CMUC^1JJh^G9doF?f`V=re~c(n<>Idi3QAo3q(kj+s0|La&Y{*iRMMdW z4%O#S-44~|P%ek!4rMx&K~x4Ze|~`|s7>ABmlT!eCjX69fJQ(gpb^jrXaqC@ z8Uc-fMnEH=5zq)|1T+E~0gZr0KqH_L_%TJm!T?eDSxJ4u!$0LvZ#mS@I@Bj6^)V0s8A*M_1KE#A`2!x<&Ztlya;OhV>OG$Szr8!@ zm7)p*Fbww|`cgO`cpWcVpdyZd1I|Mn0W}UdAt-~26Am~4GDH(_0FA>!O*Fbu<3>T_ z&XxWFckcWH{s0#)J$+8yk6sWL?kah1$my4=uBQ5_yZfr?JfACjbA3?j;6!>q>bq7dG9poK-9an_D`g@qu%Ojkmt1Jh$P5O(rdL| zZqh5Ywl?Xds28JNhe;AgqMoj`xk*n(JrVVItqoHtpV;ty_Ne>@y1n@gbhRxizm;xVKH09eMCB9fwrz^qSZm#aJUuRZ*G=X79j5EiT5Bfq z?6GXiwYIB4dN}H#s0X7Sh`PVl-A%fu)}2khE1|qzuDfd8z97$i%eGvrqgF+&>}o2{ zU+eRxQa*h6j&5x7y&R>kZqhZimNeWrubQB$=}YSPKICg-I^ zT}`BgQEgX)lwWdqR+EEtTGXjgr$ptK96oPOla7x%F6!8*V`|N6(x6tF8Xhz}Hdhlh zJ8B{-A2fX4j4RT8U9CtPqSi&Fu9oDxSn3f=@*NG+^=8x?QMX0i8g)z5%~97!T^Dsl z)QYIfqb`fOH0qM5bE6hVofCC-)LBs{M$M0!7j;6^c+^ zren=?Ob$Q)KezpA`1$|8^Bn+xT z+*_V;N8Pr%KCG$_E9*0sm**`j>(a93&;REmFB;DO7q(X(ZT|mjng9RLewV)q;EVQ? z_QUo-{x*QU?T&U^`&|A;fDP^1c1?R%ds}-$egQuO2oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkLHe*#CH%$JG!p0e&P>#nlyEbET4ZZGSrWqrA<+seANtWTA7OIbIU zbyHb4mi4i+K3e|i^>wVjzWn8pI_m$g`Tv}9Y*W_RWt}MNtl|8B#=`zx0B1~q009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF91$@8KVqQ>5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7dXUj@wn`+DV^Hvs|!2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5I7<*od3^k|Cy2h_#r@m009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjYi zCNMERHkKBpN%Q}nT{x#pfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7Mg+?I ze=;rd|NoDmNCE^15FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAkb3*^Z%Y+IHyg3 z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjX@1kC?OFeCv21PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oUJ0fcbwWto)hb!CDb4lix4 zDN;)g*H*LsA-mZ$UNmh`Gzf~eX!l1Ev<~9dEyygM?Wo^Ir`uRAs*!=bc73N>3GDrB=r$044Gj%@rvA`>nU-NUm zOWemjKlGG6AKWVo41d=Un53HSzCY}bToKvbORY|W+^+0w)w}Iw(rk5FYOC9+=|4Ny z&$Qa4)Toul3Gsz;F~43EN~_Do=Y-7z=bs6PwP{R-?N|L;gqs)I2+0 z%~g4ej8`tak8)PZeN>`?RiSWkWrdb?^b=knzfQYsZ4D(vEFCOI=`w|6cLGxapR+|=ah}A9TvcVBKtH^eM)KMxseU)qt5xRpYO2JBf7?vwt zMW+5)?%vwF0H#o(+a7FnM&&}m==410YcN(z0(X&^h_cnr>$Gu$DJkFiUeLUs#ICnIh-}E?ll-*|z;BiKUj?;O> zavUC~op*nOd2{w+y0yJfroB6~F$IUlYX*$?|9B@~-`R#%(rl^BQ<$E&-r6F4Ro>dJ z3bgT9FI|9^6xu6liDJMXITdB^@|q=@r=|JT=*|q#6$={Gy0Kciv@yK$m@>meHuEy$ zjDo>6#thL_b~Pl!*vq8X2S*!e`R_85{zx>+-g`!~Lo+iyd*@NWX@ky4n+2K?_DVW{ z<>{6&R9G*L)y&5BZDq~#4vTt_#I(vz`~8ukXjYlqkZWslk95=;ZMNNUlVj`8EoINa zR+Vls+F=-Xbv_A~Gff3|0&2^ROZ|fmmr#+3zX=bE# z_w@vOIc;>!F~Jmet1$8PJWR{WDrK50rY(XDWJB1*12bMNM!8=cSy-Zl<6^y@%PEqaOG%I{Sx-nxI-3!tY+MnOO1dG+k{lNkjcg`aZ^}|5*$^}JM7@z} zBxrg`OiG!Cn9a#aF_V;<5|N}-T9L$Dy_wC`bBaiE4ag~$NkC4Cw3r~7x|GQ_Q*k9D zDrqU3&M1nQ&d6CYnN!lDoNL5HsZN@Sbgq%DH$kZ0OlDKbj2NqvH0acj;KWc^B`!6Z ziByA-TuyA{q`0J{8e%S!k|8IMlryP>C^b{rbiG+`#xfbP(Nr?2Ts$Xc8!{+fPby-) zkxYs4bgCiMm1epLJV+{@N+(HEOs29FW-~5kGg1=}WSXgzOmb-;LmJt5GMUSWiC8KH zM3uOhYm%4V`4LtQnHzPOl&j&cCwj{iMcdXi_&N|V(BatN|GL7z06aQ z3ar3v$;PZ~d@-?@3h}?k`1qR)|0Vw0{3rN7=7R`$4vUV1Jw+gpY<`11txvH0eyXs7Wz9=k@wB`m`otx zyIi@_?cMBe%L;)<5md7He81Z=okiIB z%?ut7`IsmZ@ZD;45>gHJjqa_*8)oc#r+tj*j#aj|&G@5JKIRz257bt>ZzYQafypq~ zCLamt5oN8Ui#^56=*dYRlYkUDf<0!RP}AOR$R1dsp{Kmter2_S)oNg%`>XNPke^8qfx zYV#R$er}G1IgBGg?lD#$wa-4rW!SN^6*G@=DRTkdF-5`8a35gp^Ah1Ba6`UxE+RA! z_tQIqU>rU@1vlRtvkp^KMg2F#1nJRmejyOy5{7!tNd+opP8jt4VeZM{(c%r?H1`B+ z&KXS5)4L`DZg?Nz;_g5=H%GO1P6>E?+*|_RQ*;-ky8zuy(w(2~d~`QKcV4>V z=+47&Oh8}%f0g0?ho21p5&up8@A%K~p9=p)_`Cck`H#XK0o%OHm-%A&%ls++Soqbq zuz;8q5pwC#>78S=)&42( zV*zuy|2^JJV2}0w$GoY)_yzwX-Vd;j6@Pzt&KqT%3;Z`iN4=sej4t|5dyg^0RsXQ} zxOqUA{k21D-TzT`n{O`#@(!im{|8)I-ikBGvyZqPqFTj)j zukydlzr?rr%lt9~oadlL_DxBDKp0*m@JsR>y3ZUlM@oKKhTV%X);eC{| zQtqP?6|4${iz_R%tg9Cb`ARYR;b*7)5m99Ci>h326L--%@%2l%fMM5X1eoWB^EEU#Aa+> zQAoevs<+y-h(oMyF_#UF$XP|U1Eh{p+3BlfYlzStL{SP>>cg;H=_)ey&vN(H-UTp) z3f=Z#t1~JW3PyKNP5C1=@T;2USDL1Z-06^BK~`>%+NkQfm$?O}onGdS-ZLMY&$-ip z?Twzj8ojp>)I9Fxs^)R-RPN&`r^mUY;ryn@xufhpa{!MsDs-IABbMXvIPJXq8_b)t z7t^ing);5kp^YgxG+r}c#Q(=T`TEW_w322^WuC(Hy!F-=>8tYAc2%H_$9m}kw4~5p zQA-p9{>Z5)dzaTN(L629uSR!ffUa23sMd|u(xr{zmB*ABCbF5A8D|s>t}$kauCl8k z8OB~Fy*@bFNXvhhne<1ZQTE<5njM;%>DfDv0!|xrM%paUjIdYI0W43qjG@AMaja%G zwr?wIo_AQ(gCwR^cG~Zc6h*Vj+=g6RlY6A2)@ZZsj+-1?e{Lyz4z{Xvi_s3lxU2I? zxSVM!xD!xY-d*zfBR8St+p$_cn-?0V9!+U$|7_xZqdZ0z$enDT-1aHk0}63;!rmqh$w9((z2dG}BJ&np~W~6oZ^#pr4ZFJ4!i#+`_ zdsVZX{tM7YBegzxVL&?2YjYlQ4!dzhp6Gz_pM;dr-LtGea_ks;muPt!x*2~wjyyJv zP@BmaArH;cF3ar!)T7Hf^^2r7{%7tp{Oa__rv7W{;-uui*v5{3LE80>2h&YCSNhS zY1FqWFuW+N3!`m;kgo_!a^KPaE;UXHdpR$aFX%R}i|AKxnuInHj%f9UaHf3m!pK=I zuZ6w{*ms`xW~i2Q(>1k@Y1o;{;;80E2Z_eh4P@T9&LgMM%J9-l!c*6t65!9;;;^hx z7<}Ebp&x{S=Liled8Pv6dP{n2frV%7`p{O#WDjjlC}>u3 zW$~v^y7cZ3ts@kCkg^9+a(rcIx{cZqSm`+!Cy%!np;Xa&vb!hd{E^KlyL;8utyq>- zdCy^xWe`;C5ocTKjNCJivDayu9c^i?JWR~h=r7zpqV-@u`n2mJO%quO=+RMUBk7E^ zwUy3@!!?;!9z#{FYpij_vW)8x*V#8Y^fr6T_R^W-9LHtf6x56HpsADx4W+E>!2TWi zL-cEmtIc}xIp@sLy4<@?)Nz-gJ*H|3-Hu9bA0!1GIY_fG?T~OxJ81pGGHDWM3=~ou z{-X9^&OBPSF^rzy%UOmJXdb&a3A#Si-YKg+)k-~W54gMB<15u%$1dsp{Kmter2_OL^@a`dSKgk9eHn5TV)%P(Qp0pH8 z*6R&fB$*5mli6HTtj9B1QOPD0DMd)KL7ElPYr%V9ex5Xz2Ca4j9{PBmDC&pkGZYKj zyGk74{aUH9a9W6KxBo9x)NW6{P9Q*&hZMKdNpYFf2iL7I2(2kSqPvpy0 zgQ!5^^tEdn@L+cT+O>RR3tqa^S0RUP@7lHHt}@sn9Tj5afqEk*HroSLB@m+>)`o}V zHoQ-xO|D(L1y3AyZ-IPoKwsc;?V9!iu@%U&zo_0;?aDT^=ak?%D|l8H)Y!D1MEUV( zMCqi+>Lb)FU5T#9{ele7ytVg;%n#JAcILgMD)5dt(5TxP%^g&FK&<)j z)Y(C+Q8NM1S6M+u3vwfwlv7enB=JNB{IZ!7b9E^#W+W+-KbQU6gdJ*W{#bPiafvp9g>0fArHaKQ qyrlu}|9|&f;!sm0fCP{L5 #include #include +#include +#include #include "flutter_window.h" #include "utils.h" -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. +const wchar_t* kSingleInstanceMutex = L"auth0flutter_single_instance_mutex"; +const wchar_t* kRedirectPipeName = L"\\\\.\\pipe\\auth0flutter_pipe"; + +// Forward URI to first instance (pipe client) +void ForwardToFirstInstance(const wchar_t* uri) { + HANDLE hPipe = CreateFileW( + kRedirectPipeName, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); + + if (hPipe != INVALID_HANDLE_VALUE) { + DWORD written = 0; + size_t len = (wcslen(uri) + 1) * sizeof(wchar_t); + WriteFile(hPipe, uri, (DWORD)len, &written, NULL); + CloseHandle(hPipe); + } +} + +// Bring first instance window to foreground +void BringExistingWindowToFront() { + HWND hwnd = FindWindowW(L"FLUTTER_RUNNER_WIN32_WINDOW", NULL); + if (hwnd) { + ShowWindow(hwnd, SW_RESTORE); + SetForegroundWindow(hwnd); + } +} + +// Pipe server (runs in first instance) +void StartPipeServer() { + std::thread([] { + while (true) { + HANDLE hPipe = CreateNamedPipeW( + kRedirectPipeName, + PIPE_ACCESS_INBOUND, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + 1, 0, 0, 0, NULL); + + if (hPipe == INVALID_HANDLE_VALUE) { + return; + } + + if (ConnectNamedPipe(hPipe, NULL)) { + wchar_t buffer[2048]; + DWORD read = 0; + if (ReadFile(hPipe, buffer, sizeof(buffer), &read, NULL)) { + buffer[read / sizeof(wchar_t)] = L'\0'; + + // Expose to plugin + SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", buffer); + + // Bring app to front when redirect arrives + BringExistingWindowToFront(); + } + } + DisconnectNamedPipe(hPipe); + CloseHandle(hPipe); + } + }).detach(); +} + +int APIENTRY wWinMain(_In_ HINSTANCE instance, + _In_opt_ HINSTANCE prev, + _In_ wchar_t* command_line, + _In_ int show_command) { if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - flutter::DartProject project(L"data"); + // Ensure single instance + HANDLE hMutex = CreateMutexW(NULL, TRUE, kSingleInstanceMutex); + if (hMutex && GetLastError() == ERROR_ALREADY_EXISTS) { + // Already running → forward URI (if present) and exit + if (command_line && wcslen(command_line) > 0) { + ForwardToFirstInstance(command_line); + } + return 0; + } + + // First instance + if (command_line && wcslen(command_line) > 0) { + SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", command_line); + } else { + SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", L""); + } - std::vector command_line_arguments = - GetCommandLineArguments(); + StartPipeServer(); + // Flutter bootstrap + flutter::DartProject project(L"data"); + std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); @@ -40,4 +114,4 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, ::CoUninitialize(); return EXIT_SUCCESS; -} +} \ No newline at end of file diff --git a/auth0_flutter/windows/.vs/CMake Overview b/auth0_flutter/windows/.vs/CMake Overview new file mode 100644 index 000000000..e69de29bb diff --git a/auth0_flutter/windows/.vs/ProjectSettings.json b/auth0_flutter/windows/.vs/ProjectSettings.json new file mode 100644 index 000000000..8f0d73346 --- /dev/null +++ b/auth0_flutter/windows/.vs/ProjectSettings.json @@ -0,0 +1,3 @@ +{ + "CurrentProjectSetting": "x64-Debug" +} \ No newline at end of file diff --git a/auth0_flutter/windows/.vs/VSWorkspaceState.json b/auth0_flutter/windows/.vs/VSWorkspaceState.json new file mode 100644 index 000000000..287f4fc17 --- /dev/null +++ b/auth0_flutter/windows/.vs/VSWorkspaceState.json @@ -0,0 +1,12 @@ +{ + "OutputFoldersPerTargetSystem": { + "Local Machine": [ + "out\\build\\x64-Debug", + "out\\install\\x64-Debug" + ] + }, + "ExpandedNodes": [ + "" + ], + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/auth0_flutter/windows/.vs/slnx.sqlite b/auth0_flutter/windows/.vs/slnx.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..5e3038ecd082ca3251a673321533d6dfbd2ec941 GIT binary patch literal 90112 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 literal 0 HcmV?d00001 diff --git a/auth0_flutter/windows/.vs/windows/FileContentIndex/f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx b/auth0_flutter/windows/.vs/windows/FileContentIndex/f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx new file mode 100644 index 0000000000000000000000000000000000000000..27bc5077fad77b1701016e8120aa0b100a83ca4b GIT binary patch literal 42428 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 literal 0 HcmV?d00001 diff --git a/auth0_flutter/windows/.vs/windows/v17/DocumentLayout.json b/auth0_flutter/windows/.vs/windows/v17/DocumentLayout.json new file mode 100644 index 000000000..c70c0e147 --- /dev/null +++ b/auth0_flutter/windows/.vs/windows/v17/DocumentLayout.json @@ -0,0 +1,12 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\Administrator\\Documents\\auth0-flutter\\auth0_flutter\\windows\\", + "Documents": [], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [] + } + ] +} \ No newline at end of file diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index ae3819778..ec9bea543 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -55,6 +55,7 @@ target_include_directories(${PLUGIN_NAME} INTERFACE # === vcpkg dependencies === # These are resolved via vcpkg.json automatically (cpprestsdk, boost) find_package(cpprestsdk CONFIG REQUIRED) +find_package(OpenSSL REQUIRED) find_package(Boost REQUIRED COMPONENTS system date_time regex) # Link Flutter + vcpkg dependencies @@ -62,6 +63,8 @@ target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin cpprestsdk::cpprest + OpenSSL::SSL + OpenSSL::Crypto Boost::system Boost::date_time Boost::regex diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index 0bd647c7b..df7192f37 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -1,3 +1,7 @@ +#define _CRT_SECURE_NO_WARNINGS +#define _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING +#define _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING +#define NOMINMAX #include "auth0_flutter_plugin.h" // This must be included before many other Windows headers. @@ -33,52 +37,203 @@ using namespace web::http::client; using namespace web::http::experimental::listener; namespace auth0_flutter { + void DebugPrint(const std::string& msg) { + OutputDebugStringA((msg + "\n").c_str()); +} // -------------------- PKCE Helpers -------------------- // Base64 URL-safe encode without padding -std::string base64UrlEncode(const unsigned char* data, size_t len) { - static const char* chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - - std::string out; - int val = 0, valb = -6; - for (size_t i = 0; i < len; i++) { - val = (val << 8) + data[i]; - valb += 8; - while (valb >= 0) { - out.push_back(chars[(val >> valb) & 0x3F]); - valb -= 6; +// Helper: Base64 URL-safe encode (no padding, + → -, / → _) +std::string base64UrlEncode(const std::vector& data) { + static const char* b64chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string result; + size_t i = 0; + unsigned char a3[3]; + unsigned char a4[4]; + + for (size_t pos = 0; pos < data.size();) { + int len = 0; + for (i = 0; i < 3; i++) { + if (pos < data.size()) { + a3[i] = data[pos++]; + len++; + } else { + a3[i] = 0; + } + } + + a4[0] = (a3[0] & 0xfc) >> 2; + a4[1] = ((a3[0] & 0x03) << 4) + ((a3[1] & 0xf0) >> 4); + a4[2] = ((a3[1] & 0x0f) << 2) + ((a3[2] & 0xc0) >> 6); + a4[3] = a3[2] & 0x3f; + + for (i = 0; i < 4; i++) { + if (i <= (size_t)(len + 0)) { + result += b64chars[a4[i]]; + } else { + result += '='; + } + } } - } - if (valb > -6) out.push_back(chars[((val << 8) >> (valb + 8)) & 0x3F]); - return out; + + // Make it URL-safe + for (auto& c : result) { + if (c == '+') c = '-'; + if (c == '/') c = '_'; + } + + // Strip padding '=' + while (!result.empty() && result.back() == '=') { + result.pop_back(); + } + + return result; } +// Generate random code verifier (32 bytes -> URL-safe string) std::string generateCodeVerifier() { - std::array buffer; - if (RAND_bytes(buffer.data(), buffer.size()) != 1) { - throw std::runtime_error("Failed to generate random bytes for PKCE"); - } + std::vector buffer(32); + if (RAND_bytes(buffer.data(), static_cast(buffer.size())) != 1) { + throw std::runtime_error("Failed to generate random bytes"); + } + return base64UrlEncode(buffer); +} - // URL-safe chars - static const char* chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +// Generate code challenge from verifier (SHA256 + base64URL) +std::string generateCodeChallenge(const std::string& verifier) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(verifier.data()), + verifier.size(), + hash); - std::string verifier; - for (auto b : buffer) { - verifier.push_back(chars[b % 64]); - } - return verifier; + std::vector digest(hash, hash + SHA256_DIGEST_LENGTH); + return base64UrlEncode(digest); } -std::string generateCodeChallenge(const std::string& verifier) { - unsigned char hash[SHA256_DIGEST_LENGTH]; - SHA256(reinterpret_cast(verifier.data()), - verifier.size(), hash); - return base64UrlEncode(hash, SHA256_DIGEST_LENGTH); + +// ---------- Helpers: URL-decode, safe query parse, and waitForAuthCode (custom scheme) ---------- + +static std::string UrlDecode(const std::string& str) { + std::string out; + out.reserve(str.size()); + for (size_t i = 0; i < str.size(); ++i) { + char c = str[i]; + if (c == '%') { + if (i + 2 < str.size()) { + std::string hex = str.substr(i + 1, 2); + char decoded = (char)strtol(hex.c_str(), nullptr, 16); + out.push_back(decoded); + i += 2; + } + // else malformed percent-encoding: skip + } else if (c == '+') { + out.push_back(' '); + } else { + out.push_back(c); + } + } + return out; +} + +static std::map SafeParseQuery(const std::string& query) { + std::map params; + size_t start = 0; + while (start < query.size()) { + size_t eq = query.find('=', start); + if (eq == std::string::npos) { + break; // no more key=value pairs + } + std::string key = query.substr(start, eq - start); + size_t amp = query.find('&', eq + 1); + std::string value; + if (amp == std::string::npos) { + value = query.substr(eq + 1); + start = query.size(); + } else { + value = query.substr(eq + 1, amp - (eq + 1)); + start = amp + 1; + } + params[UrlDecode(key)] = UrlDecode(value); + } + return params; +} + +// Safe UTF conversions (wchar_t <-> UTF-8) +static std::string WideToUtf8(const std::wstring& wstr) { + if (wstr.empty()) return {}; + int size_needed = ::WideCharToMultiByte(CP_UTF8, 0, wstr.data(), + (int)wstr.size(), nullptr, 0, nullptr, nullptr); + if (size_needed <= 0) return {}; + std::string str(size_needed, 0); + ::WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), &str[0], size_needed, nullptr, nullptr); + return str; } +// Poll environment variable PLUGIN_STARTUP_URL for redirect URI (set by runner/main on startup or IPC). +// Example stored value: auth0flutter://callback?code=AUTH_CODE&state=xyz +static std::string waitForAuthCode_CustomScheme(const std::string& expectedRedirectBase, int timeoutSeconds = 180) { + const int sleepMs = 200; + int elapsed = 0; +auto readAndClearEnv = []() -> std::string { + // Ask Windows how many wchar_t characters are needed (including null) + DWORD bufSize = GetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", NULL, 0); + if (bufSize == 0) return std::string(); + + std::vector buf(bufSize); + DWORD ret = GetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", buf.data(), bufSize); + if (ret == 0 || ret >= bufSize) { + return std::string(); + } + + // Clear it so it's not consumed twice + SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", L""); + + // Convert wide -> UTF-8 safely + std::wstring wstr(buf.data(), ret); + return WideToUtf8(wstr); +}; + + + while (elapsed < timeoutSeconds * 1000) { + std::string uri = readAndClearEnv(); + if (!uri.empty()) { + // DebugPrint("Received startup URI: " + uri); + // Optionally: verify prefix matches expectedRedirectBase (e.g. "auth0flutter://callback") + if (!expectedRedirectBase.empty()) { + if (uri.rfind(expectedRedirectBase, 0) != 0) { + // DebugPrint("Warning: received URI does not start with expected redirect base"); + // continue — but still try to parse if present + } + } + // find query + auto qpos = uri.find('?'); + if (qpos == std::string::npos) { + return std::string(); // no query params + } + std::string query = uri.substr(qpos + 1); + auto params = SafeParseQuery(query); + auto it = params.find("code"); + if (it != params.end()) { + return it->second; + } else { + // maybe error param present + if (params.find("error") != params.end()) { + // DebugPrint("OAuth returned error: " + params["error"]); + return std::string(); + } + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + elapsed += sleepMs; + } + + // timeout + return std::string(); +} + + // -------------------- Local Redirect Listener -------------------- std::string waitForAuthCode(const std::string& redirectUri) { @@ -103,13 +258,11 @@ std::string waitForAuthCode(const std::string& redirectUri) { while (authCode.empty()) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } - listener.close().wait(); return authCode; } // -------------------- Token Exchange -------------------- - web::json::value exchangeCodeForTokens( const std::string& domain, const std::string& clientId, @@ -117,8 +270,7 @@ web::json::value exchangeCodeForTokens( const std::string& code, const std::string& codeVerifier) { - http_client client( - U("https://" + utility::conversions::to_string_t(domain))); + http_client client(U("https://" + utility::conversions::to_string_t(domain))); http_request request(methods::POST); request.set_request_uri(U("/oauth/token")); @@ -126,25 +278,74 @@ web::json::value exchangeCodeForTokens( web::json::value body; body[U("grant_type")] = web::json::value::string(U("authorization_code")); - body[U("client_id")] = - web::json::value::string(utility::conversions::to_string_t(clientId)); - body[U("code")] = - web::json::value::string(utility::conversions::to_string_t(code)); - body[U("redirect_uri")] = - web::json::value::string(utility::conversions::to_string_t(redirectUri)); - body[U("code_verifier")] = - web::json::value::string(utility::conversions::to_string_t(codeVerifier)); - + body[U("client_id")] = web::json::value::string(utility::conversions::to_string_t(clientId)); + body[U("code")] = web::json::value::string(utility::conversions::to_string_t(code)); + body[U("redirect_uri")] = web::json::value::string(utility::conversions::to_string_t(redirectUri)); + body[U("code_verifier")] = web::json::value::string(utility::conversions::to_string_t(codeVerifier)); + DebugPrint("codeVerifier = " + codeVerifier); + DebugPrint("redirect_uri = " + redirectUri); request.set_body(body); auto response = client.request(request).get(); + + // ---- Debug: status & headers ---- + DebugPrint("HTTP Status: " + std::to_string(response.status_code())); + for (const auto& h : response.headers()) { + DebugPrint("Header: " + utility::conversions::to_utf8string(h.first) + + " = " + utility::conversions::to_utf8string(h.second)); + } + + // ---- Read response body as string ---- + auto bodyStr = response.extract_string().get(); + DebugPrint("Response Body: " + utility::conversions::to_utf8string(bodyStr)); + if (response.status_code() != status_codes::OK) { - throw std::runtime_error("Token request failed"); + throw std::runtime_error("Token request failed: " + utility::conversions::to_utf8string(bodyStr)); } - return response.extract_json().get(); + // ---- Parse JSON if successful ---- + return web::json::value::parse(bodyStr); } +// web::json::value exchangeCodeForTokens( +// const std::string& domain, +// const std::string& clientId, +// const std::string& redirectUri, +// const std::string& code, +// const std::string& codeVerifier) { +// DebugPrint("domain=" + domain); +// DebugPrint("clientId=" + clientId); +// DebugPrint("redirectUri=" + redirectUri); +// DebugPrint("code=" + code); +// DebugPrint("codeVerifier=" + codeVerifier); +// http_client client( +// U("https://" + utility::conversions::to_string_t(domain))); + +// http_request request(methods::POST); +// request.set_request_uri(U("/oauth/token")); +// request.headers().set_content_type(U("application/json")); + +// web::json::value body; +// body[U("grant_type")] = web::json::value::string(U("authorization_code")); +// body[U("client_id")] = +// web::json::value::string(utility::conversions::to_string_t(clientId)); +// body[U("code")] = +// web::json::value::string(utility::conversions::to_string_t(code)); +// body[U("redirect_uri")] = +// web::json::value::string(utility::conversions::to_string_t(redirectUri)); +// body[U("code_verifier")] = +// web::json::value::string(utility::conversions::to_string_t(codeVerifier)); + +// request.set_body(body); + +// auto response = client.request(request).get(); +// if (response.status_code() != status_codes::OK) { +// throw std::runtime_error("Token request failed"); +// } + +// return response.extract_json().get(); +// } + // -------------------- Plugin Impl -------------------- void Auth0FlutterPlugin::RegisterWithRegistrar( @@ -167,28 +368,55 @@ void Auth0FlutterPlugin::RegisterWithRegistrar( Auth0FlutterPlugin::Auth0FlutterPlugin() {} Auth0FlutterPlugin::~Auth0FlutterPlugin() {} + + void Auth0FlutterPlugin::HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result) { if (method_call.method_name().compare("webAuth#login") == 0) { + // Top-level args should be a map const auto* args = std::get_if(method_call.arguments()); if (!args) { - result->Error("invalid_args", "Arguments must be a map"); + result->Error("bad_args", "Expected a map as arguments"); return; } - std::string clientId = - std::get(args->at(flutter::EncodableValue("clientId"))); - std::string domain = - std::get(args->at(flutter::EncodableValue("domain"))); - std::string redirectUri = - std::get(args->at(flutter::EncodableValue("redirectUri"))); + // Extract "account" map + auto accountIt = args->find(flutter::EncodableValue("_account")); + if (accountIt == args->end()) { + result->Error("bad_args", "Missing 'account' key"); + return; + } + + const auto* accountMap = std::get_if(&accountIt->second); + if (!accountMap) { + result->Error("bad_args", "'account' is not a map"); + return; + } + + // Extract clientId and domain + std::string clientId; + std::string domain; + + if (auto it = accountMap->find(flutter::EncodableValue("clientId")); + it != accountMap->end()) { + clientId = std::get(it->second); + } + + if (auto it = accountMap->find(flutter::EncodableValue("domain")); + it != accountMap->end()) { + domain = std::get(it->second); + } + + std::string redirectUri = "auth0flutter://callback"; + try { // 1. PKCE std::string codeVerifier = generateCodeVerifier(); std::string codeChallenge = generateCodeChallenge(codeVerifier); - + DebugPrint("codeVerifier = " + codeVerifier); + DebugPrint("codeChallenge = " + codeChallenge); // 2. Build Auth URL std::ostringstream authUrl; authUrl << "https://" << domain << "/authorize?" @@ -198,12 +426,13 @@ void Auth0FlutterPlugin::HandleMethodCall( << "&scope=openid%20profile%20email" << "&code_challenge=" << codeChallenge << "&code_challenge_method=S256"; + DebugPrint("authUrl = " + authUrl.str()); // 3. Open browser ShellExecuteA(NULL, "open", authUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); // 4. Wait for callback - std::string code = waitForAuthCode(redirectUri); + std::string code = waitForAuthCode_CustomScheme(redirectUri, 180); // 5. Exchange code for tokens auto tokens = @@ -219,4 +448,4 @@ void Auth0FlutterPlugin::HandleMethodCall( } } -} // namespace auth0_flutter \ No newline at end of file +} // namespace auth0_flutter From 9b6d64725a866cf85f0c587e6dbbf83d74f0ce51 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Mon, 8 Sep 2025 15:32:45 +0530 Subject: [PATCH 06/18] .env.example added --- auth0_flutter/example/.env.example | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 auth0_flutter/example/.env.example diff --git a/auth0_flutter/example/.env.example b/auth0_flutter/example/.env.example new file mode 100644 index 000000000..894cc348f --- /dev/null +++ b/auth0_flutter/example/.env.example @@ -0,0 +1,17 @@ +# +# Your Auth0 Domain. +# +AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN +# +# The Client Id of your Auth0 application. +# +AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID +# +# The custom scheme for the Android callback and logout URLs. +# Only set a value if you prefer not to use the default scheme (https). +# If you set a value: +# 1. Update the Android callback and logout URLs in the +# settings page of your Auth0 application with the custom scheme value. +# 2. Update the scheme value in android/app/src/main/res/values/strings.xml +# +AUTH0_CUSTOM_SCHEME=YOUR_AUTH0_CUSTOM_SCHEME \ No newline at end of file From c3f7dbbd0f43b4b42dfc28845a4166b406a50c38 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Wed, 17 Dec 2025 11:16:21 +0000 Subject: [PATCH 07/18] Adds PKCE flow --- .github/actions/setup-darwin/action.yml | 2 +- .github/actions/smoke-tests-darwin/action.yml | 2 +- .github/actions/unit-tests-darwin/action.yml | 2 +- .github/workflows/main.yml | 32 +- appium-test/package-lock.json | 6 +- auth0_flutter/CHANGELOG.md | 8 + auth0_flutter/EXAMPLES.md | 31 +- auth0_flutter/MIGRATION_GUIDE.md | 145 ++++ auth0_flutter/README.md | 60 +- auth0_flutter/android/build.gradle | 15 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../Auth0FlutterAuthMethodCallHandler.kt | 24 +- .../Auth0FlutterDPoPMethodCallHandler.kt | 30 + .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 63 +- .../CredentialsManagerMethodCallHandler.kt | 127 +++- .../request_handlers/MethodCallRequest.kt | 4 +- .../api/ClearDPoPKeyApiRequestHandler.kt | 40 + .../api/GetDPoPHeadersApiRequestHandler.kt | 74 ++ .../api/UserInfoApiRequestHandler.kt | 4 +- .../api/UtilityRequestHandler.kt | 12 + .../web_auth/LoginWebAuthRequestHandler.kt | 11 +- .../Auth0FlutterAuthMethodCallHandlerTest.kt | 13 +- .../Auth0FlutterDPoPMethodCallHandlerTest.kt | 71 ++ .../auth0_flutter/Auth0FlutterPluginTest.kt | 43 +- ...CredentialsManagerMethodCallHandlerTest.kt | 361 +++++++-- .../LoginWebAuthRequestHandlerTest.kt | 26 +- .../LogoutWebAuthRequestHandlerTest.kt | 6 +- .../api/LoginApiRequestHandlerTest.kt | 33 +- .../api/LoginWithOtpApiRequestHandlerTest.kt | 31 +- .../api/RenewApiRequestHandlerTest.kt | 81 ++- .../api/UserInfoApiRequestHandlerTest.kt | 90 ++- .../GetCredentialsRequestHandlerTest.kt | 29 +- .../RenewCredentialsRequestHandlerTest.kt | 30 +- .../SaveCredentialsRequestHandlerTest.kt | 1 + .../Classes/AuthAPI/AuthAPIHandler.swift | 12 +- .../AuthAPIUserInfoMethodHandler.swift | 5 +- .../CredentialsManagerHandler.swift | 55 +- .../DPoP/DPoPClearKeyMethodHandler.swift | 20 + .../DPoP/DPoPGetHeadersMethodHandler.swift | 55 ++ .../darwin/Classes/DPoP/DPoPHandler.swift | 50 ++ .../Classes/SwiftAuth0FlutterPlugin.swift | 1 + .../WebAuth/WebAuthLoginMethodHandler.swift | 5 + auth0_flutter/darwin/auth0_flutter.podspec | 8 +- .../example/android/app/build.gradle | 3 +- .../auth0_flutter_example/MainActivity.kt | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 3 +- auth0_flutter/example/android/settings.gradle | 4 +- .../Tests/AuthAPI/AuthAPIHandlerTests.swift | 2 +- .../AuthAPIRenewMethodHandlerTests.swift | 8 +- .../ios/Tests/AuthAPI/AuthAPISpies.swift | 23 +- .../AuthAPIUserInfoMethodHandlerTests.swift | 6 +- ...dentialsManagerGetMethodHandlerTests.swift | 12 +- .../CredentialsManagerHandlerTests.swift | 85 ++- auth0_flutter/example/ios/Tests/Mocks.swift | 2 - .../Tests/WebAuth/WebAuthHandlerTests.swift | 2 +- .../WebAuthLoginMethodHandlerTests.swift | 21 +- .../ios/Tests/WebAuth/WebAuthSpies.swift | 6 + auth0_flutter/example/lib/example_app.dart | 77 ++ auth0_flutter/example/pubspec.yaml | 4 +- auth0_flutter/example/web/index.html | 68 +- .../DPoP/DPoPClearKeyMethodHandler.swift | 1 + .../DPoP/DPoPGetHeadersMethodHandler.swift | 1 + .../ios/Classes/DPoP/DPoPHandler.swift | 1 + auth0_flutter/ios/auth0_flutter.podspec | 8 +- auth0_flutter/lib/auth0_flutter.dart | 83 ++- auth0_flutter/lib/auth0_flutter_web.dart | 34 +- .../lib/src/mobile/authentication_api.dart | 5 +- .../lib/src/mobile/credentials_manager.dart | 25 +- .../lib/src/mobile/web_authentication.dart | 9 +- auth0_flutter/lib/src/version.dart | 2 +- .../src/web/auth0_flutter_plugin_real.dart | 8 + .../extensions/client_options_extensions.dart | 1 + .../web/extensions/credentials_extension.dart | 2 +- auth0_flutter/lib/src/web/js_interop.dart | 5 +- .../DPoP/DPoPClearKeyMethodHandler.swift | 1 + .../DPoP/DPoPGetHeadersMethodHandler.swift | 1 + .../macos/Classes/DPoP/DPoPHandler.swift | 1 + auth0_flutter/macos/auth0_flutter.podspec | 8 +- auth0_flutter/pubspec.yaml | 4 +- .../test/mobile/authentication_api_test.dart | 29 + .../test/mobile/web_authentication_test.dart | 376 ++++++++++ .../test/web/auth0_flutter_web_test.dart | 687 +++++++++++++++--- .../windows/auth0_flutter_plugin.cpp | 8 +- auth0_flutter_platform_interface/CHANGELOG.md | 8 + .../lib/auth0_flutter_platform_interface.dart | 6 + .../src/auth/auth_dpop_headers_options.dart | 23 + .../lib/src/auth/auth_user_info_options.dart | 7 +- .../lib/src/auth/dpop_headers.dart | 14 + .../lib/src/auth/empty_request_options.dart | 8 + .../lib/src/auth0_flutter_auth_platform.dart | 4 +- .../lib/src/auth0_flutter_dpop_platform.dart | 41 ++ .../options/local_authentication.dart | 22 +- .../method_channel_auth0_flutter_auth.dart | 33 +- .../method_channel_auth0_flutter_dpop.dart | 51 ++ .../lib/src/request/dpop_request.dart | 13 + .../lib/src/request/request.dart | 6 +- .../src/web-auth/web_auth_login_options.dart | 28 +- .../lib/src/web/client_options.dart | 6 +- auth0_flutter_platform_interface/pubspec.yaml | 2 +- ...ethod_channel_auth0_flutter_auth_test.dart | 29 + .../test/web_auth_login_options_test.dart | 30 + 101 files changed, 3100 insertions(+), 581 deletions(-) create mode 100644 auth0_flutter/MIGRATION_GUIDE.md create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/ClearDPoPKeyApiRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/GetDPoPHeadersApiRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UtilityRequestHandler.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandlerTest.kt create mode 100644 auth0_flutter/darwin/Classes/DPoP/DPoPClearKeyMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/DPoP/DPoPGetHeadersMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/DPoP/DPoPHandler.swift create mode 120000 auth0_flutter/ios/Classes/DPoP/DPoPClearKeyMethodHandler.swift create mode 120000 auth0_flutter/ios/Classes/DPoP/DPoPGetHeadersMethodHandler.swift create mode 120000 auth0_flutter/ios/Classes/DPoP/DPoPHandler.swift create mode 120000 auth0_flutter/macos/Classes/DPoP/DPoPClearKeyMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/DPoP/DPoPGetHeadersMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/DPoP/DPoPHandler.swift create mode 100644 auth0_flutter_platform_interface/lib/src/auth/auth_dpop_headers_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/auth/dpop_headers.dart create mode 100644 auth0_flutter_platform_interface/lib/src/auth/empty_request_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/auth0_flutter_dpop_platform.dart create mode 100644 auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_dpop.dart create mode 100644 auth0_flutter_platform_interface/lib/src/request/dpop_request.dart diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index fb16712b2..41f5f3c85 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -65,7 +65,7 @@ runs: - id: restore-pods-cache name: Restore Pods cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: auth0_flutter/example/${{ steps.lowercase-platform.outputs.platform }}/Pods key: pods-${{ hashFiles('Podfile.lock') }}-${{ hashFiles('.xcode-version') }}-v1 diff --git a/.github/actions/smoke-tests-darwin/action.yml b/.github/actions/smoke-tests-darwin/action.yml index eb4680c73..bfa3c0a4c 100644 --- a/.github/actions/smoke-tests-darwin/action.yml +++ b/.github/actions/smoke-tests-darwin/action.yml @@ -35,7 +35,7 @@ runs: shell: bash - name: Upload xcresult bundles - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: ${{ failure() }} with: name: '${{ inputs.platform }} xcresult bundles (smoke tests)' diff --git a/.github/actions/unit-tests-darwin/action.yml b/.github/actions/unit-tests-darwin/action.yml index 49aa9a3ef..18b98971f 100644 --- a/.github/actions/unit-tests-darwin/action.yml +++ b/.github/actions/unit-tests-darwin/action.yml @@ -25,7 +25,7 @@ runs: shell: bash - name: Upload xcresult bundles - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: ${{ failure() }} with: name: '${{ inputs.platform }} xcresult bundles (unit tests)' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a730c8e3c..d1d0798e6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ env: ruby: '3.3.1' flutter: '3.x' ios-simulator: iPhone 16 - java: 11 + java: 17 jobs: @@ -88,7 +88,7 @@ jobs: flutter test --coverage --exclude-tags browser - name: Upload coverage report - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: auth0_flutter coverage path: auth0_flutter/coverage/lcov.info @@ -113,7 +113,7 @@ jobs: run: flutter test --coverage - name: Upload coverage report - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: auth0_flutter_platform_interface coverage path: auth0_flutter_platform_interface/coverage/lcov.info @@ -156,7 +156,7 @@ jobs: run: bundle exec slather coverage -x --scheme Runner Runner.xcodeproj - name: Upload coverage report - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: iOS coverage path: auth0_flutter/example/ios/cobertura @@ -294,13 +294,13 @@ jobs: run: ./gradlew koverXmlReportDebug - name: Upload coverage report - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: Android coverage path: auth0_flutter/example/build/app/coverage.xml - name: Upload test results - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: ${{ failure() }} with: name: Test results @@ -397,14 +397,14 @@ jobs: # USER_EMAIL=$USER_EMAIL USER_PASSWORD=$USER_PASSWORD node appium-test/test.js # - name: Upload recording - # uses: actions/upload-artifact@v5 + # uses: actions/upload-artifact@v6 # if: ${{ failure() }} # with: # name: 'Android - smoke tests recording' # path: recording_video.webm # - name: Upload APK - # uses: actions/upload-artifact@v5 + # uses: actions/upload-artifact@v6 # if: ${{ failure() }} # with: # name: 'Android - APK' @@ -426,52 +426,52 @@ jobs: uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - name: Download coverage report for auth0_flutter - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: auth0_flutter coverage path: coverage/auth0_flutter - name: Download coverage report for auth0_flutter_platform_interface - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: auth0_flutter_platform_interface coverage path: coverage/auth0_flutter_platform_interface - name: Download coverage report for iOS - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: iOS coverage path: coverage/ios - name: Download coverage report for Android - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: Android coverage path: coverage/android - name: Upload coverage report for auth0_flutter - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de with: name: Auth0 Flutter flags: auth0_flutter directory: coverage/auth0_flutter - name: Upload coverage report for auth0_flutter_platform_interface - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de with: name: Auth0 Flutter flags: auth0_flutter_platform_interface directory: coverage/auth0_flutter_platform_interface - name: Upload coverage report for iOS - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de with: name: Auth0 Flutter flags: auth0_flutter_ios directory: coverage/ios - name: Upload coverage report for Android - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de with: name: Auth0 Flutter flags: auth0_flutter_android diff --git a/appium-test/package-lock.json b/appium-test/package-lock.json index 3e3e69283..6f3b7c0b1 100644 --- a/appium-test/package-lock.json +++ b/appium-test/package-lock.json @@ -1380,9 +1380,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/auth0_flutter/CHANGELOG.md b/auth0_flutter/CHANGELOG.md index fd295b9bc..7a7e10b43 100644 --- a/auth0_flutter/CHANGELOG.md +++ b/auth0_flutter/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [af-v2.0.0-beta.1](https://github.com/auth0/auth0-flutter/tree/af-v2.0.0-beta.1) (2025-12-10) +[Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v1.14.0...af-v2.0.0-beta.1) + +**Added** +- Docs: Add v2.0.0 migration notice and update documentation [\#692](https://github.com/auth0/auth0-flutter/pull/692) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- Adding DPoP feature for flutter [\#667](https://github.com/auth0/auth0-flutter/pull/667) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- Updated the doc on hasValidCredentials [\#679](https://github.com/auth0/auth0-flutter/pull/679) ([pmathew92](https://github.com/pmathew92)) + ## [af-v1.14.0](https://github.com/auth0/auth0-flutter/tree/af-v1.14.0) (2025-09-24) [Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v1.13.0...af-v1.14.0) diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index ada7e7e26..c15c1e187 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -443,6 +443,8 @@ final auth0 = Auth0('YOUR_AUTH0_DOMAIN', 'YOUR_AUTH0_CLIENT_ID', You can enable an additional level of user authentication before retrieving credentials using the local authentication supported by the device, for example PIN or fingerprint on Android, and Face ID or Touch ID on iOS. +To enable this, pass a `LocalAuthentication` instance when you create your `Auth0` object. + ```dart const localAuthentication = LocalAuthentication(title: 'Please authenticate to continue'); @@ -450,6 +452,7 @@ final auth0 = Auth0('YOUR_AUTH0_DOMAIN', 'YOUR_AUTH0_CLIENT_ID', localAuthentication: localAuthentication); final credentials = await auth0.credentialsManager.credentials(); ``` +> ⚠️ On Android, your app's `MainActivity.kt` file must extend `FlutterFragmentActivity` instead of `FlutterActivity` for biometric prompts to work. Check the [API documentation](https://pub.dev/documentation/auth0_flutter_platform_interface/latest/auth0_flutter_platform_interface/LocalAuthentication-class.html) to learn more about the available `LocalAuthentication` properties. @@ -490,10 +493,16 @@ The Credentials Manager will only throw `CredentialsManagerException` exceptions ```dart try { - final credentials = await auth0.credentialsManager.credentials(); - // ... +final credentials = await auth0.credentialsManager.credentials(); +// ... } on CredentialsManagerException catch (e) { - print(e); +if (e.isNoCredentialsFound) { +print("No credentials stored."); +} else if (e.isTokenRenewFailed) { +print("Failed to renew tokens."); +} else { +print(e); +} } ``` @@ -659,9 +668,23 @@ Fetch the latest user information from the `/userinfo` endpoint. This method will yield a `UserProfile` instance. Check the [API documentation](https://pub.dev/documentation/auth0_flutter_platform_interface/latest/auth0_flutter_platform_interface/UserProfile-class.html) to learn more about its available properties. ```dart -final userProfile = await auth0.api.userInfo(accessToken: accessToken); +// Basic usage with Bearer token (default) +final userProfile = await auth0.api.userProfile(accessToken: accessToken); + +// With explicit token type (useful for DPoP tokens) +final credentials = await auth0.credentialsManager.credentials(); +final userProfile = await auth0.api.userProfile( + accessToken: credentials.accessToken, + tokenType: credentials.tokenType, // 'Bearer' or 'DPoP' +); ``` +The `tokenType` parameter specifies the type of token being used: +- `'Bearer'` (default): Standard OAuth 2.0 bearer tokens +- `'DPoP'`: DPoP (Demonstrating Proof of Possession) tokens for enhanced security + +> 💡 When using DPoP tokens, the SDK automatically handles proof generation. See the [DPoP documentation](DPOP.md) for more information. + ### Renew credentials Use a [refresh token](https://auth0.com/docs/secure/tokens/refresh-tokens) to renew the user's credentials. It's recommended that you read and understand the refresh token process beforehand. diff --git a/auth0_flutter/MIGRATION_GUIDE.md b/auth0_flutter/MIGRATION_GUIDE.md new file mode 100644 index 000000000..7f51cbc10 --- /dev/null +++ b/auth0_flutter/MIGRATION_GUIDE.md @@ -0,0 +1,145 @@ +# Migration Guide: v1.x to v2.0.0 + +## Summary + +**auth0_flutter v2.0.0** includes updates to the underlying native Auth0 SDKs to support new features including **DPoP (Demonstrating Proof of Possession)**. + +**Do I need to make changes?** +- ⚠️ **YES** - If you use biometric authentication on Android, you **MUST** update your `MainActivity` (see below) +- ✅ **NO** - If you don't use biometric authentication, your code will work without changes +- 🆕 **OPTIONAL** - You can optionally enable new features like DPoP + +## Breaking Changes + +### ⚠️ Android: MainActivity Must Extend FlutterFragmentActivity + +**If you use biometric authentication on Android**, you must change your `MainActivity.kt` to extend `FlutterFragmentActivity` instead of `FlutterActivity`. + +**Who is affected:** +- ✅ Users who call `credentialsManager.credentials()` with the `localAuthentication` parameter on Android +- ✅ Only if your `MainActivity.kt` currently extends `FlutterActivity` + +**Required change:** + +```kotlin +// ❌ Before (will cause crash with biometric auth) +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} + +// ✅ After (required for biometric auth in v2.0.0) +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity: FlutterFragmentActivity() { +} +``` + +**Why this change?** +This requirement comes from Auth0.Android SDK 3.x, which changed its biometric authentication implementation to use Fragments. The Flutter plugin requires `FlutterFragmentActivity` to properly display biometric prompts. + +**If you don't use biometric authentication,** no changes are needed, but switching to `FlutterFragmentActivity` is recommended for future compatibility. + +## Native SDK Version Updates + +This release updates the underlying native Auth0 SDKs to support new features including DPoP (Demonstrating Proof of Possession). + +### Updated SDK Versions + +| Platform | Previous Version | New Version | Changes | +|----------|-----------------|-------------|---------| +| **Android** | Auth0.Android 2.11.0 | Auth0.Android 3.11.0 | DPoP support, **biometric auth requires FlutterFragmentActivity** | +| **iOS/macOS** | Auth0.swift 2.10.0 | Auth0.swift 2.14.0 | DPoP support, improved APIs | +| **Web** | auth0-spa-js 2.0 | auth0-spa-js 2.9.0 | DPoP support, bug fixes | + +### What's New + +#### DPoP (Demonstrating Proof of Possession) Support +All platforms now support DPoP, an optional OAuth 2.0 security extension that cryptographically binds access tokens to your client, preventing token theft and replay attacks. + +**This is an opt-in feature** - your existing authentication flows will continue to work without any changes. + +To enable DPoP: +```dart +// Mobile +final credentials = await auth0.webAuthentication().login(useDPoP: true); + +// Web +final auth0Web = Auth0Web('DOMAIN', 'CLIENT_ID', useDPoP: true); +``` + +For complete DPoP documentation, see the [README](README.md#using-dpop-demonstrating-proof-of-possession). + +## Optional New Features + +You can optionally adopt these new features: +- ✅ Enable DPoP for enhanced security (optional) +- ✅ Use new DPoP API methods: `getDPoPHeaders()` and `clearDPoPKey()` (optional) + +## Requirements + +### Flutter & Dart Versions +- **Flutter SDK:** 3.24.0 or higher +- **Dart SDK:** 3.5.0 or higher + +### Platform Versions +| Platform | Minimum Version | +|----------|----------------| +| **Android** | API 21+ (Android 5.0) | +| **iOS** | iOS 14+ | +| **macOS** | macOS 11+ | + +### Android Build Requirements +**Java 8** remains the minimum requirement for Android builds. No changes to your Java setup are needed. +- `sourceCompatibility JavaVersion.VERSION_1_8` +- `targetCompatibility JavaVersion.VERSION_1_8` + +## Troubleshooting + +### Android: App crashes when using biometric authentication + +**Error:** App crashes or shows fragment-related errors when calling `credentialsManager.credentials()` with biometric authentication. + +**Solution:** Make sure your `MainActivity.kt` extends `FlutterFragmentActivity`: + +```kotlin +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity: FlutterFragmentActivity() { +} +``` + +### DPoP is not working + +**Issue:** DPoP tokens are not being generated or validated. + +**Check:** +1. Ensure you've enabled DPoP in your login call: `auth0.webAuthentication().login(useDPoP: true)` +2. Verify your Auth0 tenant supports DPoP +3. Check that you're using the credentials returned from the DPoP-enabled login + +For detailed DPoP usage instructions, see the [README DPoP section](README.md#using-dpop-demonstrating-proof-of-possession). + +## What's New in v2.0.0 + +### DPoP (Demonstrating Proof of Possession) Support +All platforms now support DPoP, an optional OAuth 2.0 security feature that cryptographically binds access tokens to a specific client, preventing token theft and replay attacks. + +**Key Features:** +- 🔒 Enhanced token security with cryptographic binding +- 🌐 Cross-platform support (Android, iOS, macOS, Web) +- 🔧 Easy opt-in with simple API +- 📱 New DPoP management methods: `getDPoPHeaders()` and `clearDPoPKey()` + +**Platform Updates:** +- **Android:** Auth0.Android 2.11.0 → 3.11.0 +- **iOS/macOS:** Auth0.swift 2.10.0 → 2.14.0 +- **Web:** auth0-spa-js 2.0 → 2.9.0 + +DPoP is completely opt-in and your existing authentication flows will continue to work without any modifications. + +## Need Help? + +- 📚 [README](README.md) - Full documentation +- 💬 [Discussions](https://github.com/auth0/auth0-flutter/discussions) - Ask questions +- 🐛 [Issues](https://github.com/auth0/auth0-flutter/issues) - Report bugs diff --git a/auth0_flutter/README.md b/auth0_flutter/README.md index 3953afb2c..d2f8c45e1 100644 --- a/auth0_flutter/README.md +++ b/auth0_flutter/README.md @@ -9,6 +9,12 @@ 📚 Documentation • 🚀 Getting started • 🌐 API reference • 💬 Feedback +## ⚠️ Important Migration Notice: v2.0.0 + +We're excited to announce the release of auth0_flutter v2.0.0! + +**For v2.0.0 users:** This version includes updates to the underlying native Auth0 SDKs to support **DPoP (Demonstrating Proof of Possession)** and other improvements. See the 👉 [Migration Guide](https://github.com/auth0/auth0-flutter/blob/main/auth0_flutter/MIGRATION_GUIDE.md) 👈 for compatibility requirements and upgrade instructions. + ## Documentation - Quickstarts: [Native](https://auth0.com/docs/quickstart/native/flutter/interactive) / [Web](https://auth0.com/docs/quickstart/spa/flutter/interactive) - our interactive guide for quickly adding login, logout and user information to your app using Auth0 @@ -22,11 +28,11 @@ ### Requirements -| Flutter | Android | iOS | macOS | -| :--------- | :-------------- | :---------------- | :---------------- | -| SDK 3.0+ | Android API 21+ | iOS 14+ | macOS 11+ | -| Dart 2.17+ | Java 8+ | Swift 5.9+ | Swift 5.9+ | -| | | Xcode 15.x / 16.x | Xcode 15.x / 16.x | +| Flutter | Android | iOS | macOS | +| :---------- | :-------------- | :---------------- | :---------------- | +| SDK 3.24.0+ | Android API 21+ | iOS 14+ | macOS 11+ | +| Dart 3.5.0+ | Java 8+ | Swift 5.9+ | Swift 5.9+ | +| | | Xcode 15.x / 16.x | Xcode 15.x / 16.x | ### Installation @@ -202,6 +208,10 @@ Re-declare the activity manually using `tools:node="remove"` in the `android/src > 💡 If your Android app is using [product flavors](https://developer.android.com/studio/build/build-variants#product-flavors), you might need to specify different manifest placeholders for each flavor. +##### Android: Biometric authentication + +> ⚠️ On Android, your app's `MainActivity.kt` file must extend `FlutterFragmentActivity` instead of `FlutterActivity` for biometric prompts to work. + ##### iOS/macOS: Configure the associated domain > ⚠️ This step requires a paid Apple Developer account. It is needed to use Universal Links as callback and logout URLs. @@ -256,7 +266,7 @@ final auth0Web = Auth0Web('YOUR_AUTH0_DOMAIN', 'YOUR_AUTH0_CLIENT_ID'); Finally, in your `index.html` add the following ` + ``` ### Logging in @@ -343,6 +353,44 @@ final credentials = await auth0Web.loginWithPopup(popupWindow: popup); For other comprehensive examples, see the [EXAMPLES.md](EXAMPLES.md) document. +### Using DPoP (Demonstrating Proof of Possession) + +Auth0 Flutter SDK supports [DPoP (Demonstrating Proof of Possession)](https://datatracker.ietf.org/doc/html/rfc9449), a security mechanism that cryptographically binds access tokens to your client, preventing token theft and replay attacks. + +**Quick Start:** + +```dart +// Mobile (Android/iOS) +final credentials = await auth0 + .webAuthentication() + .login(useDPoP: true, useHTTPS: true); + +// Web +final auth0Web = Auth0Web( + 'YOUR_AUTH0_DOMAIN', + 'YOUR_AUTH0_CLIENT_ID', + useDPoP: true, +); +``` + +**Key Benefits:** +- 🔒 Enhanced security through cryptographic token binding +- 🛡️ Protection against token theft and replay attacks +- 🌐 Full cross-platform support (Web, Android, iOS) + +**Platform Support:** + +| Feature | Web | iOS | Android | +|---------|-----|-----|---------| +| Login with DPoP | ✅ | ✅ | ✅ | +| CredentialsManager with DPoP | ✅ | ✅ | ✅ | +| Token Refresh with DPoP | ✅ | ✅ | ✅ | +| Manual DPoP APIs (`getDPoPHeaders()`, `clearDPoPKey()`) | ✅ | ✅ | ✅ | + +> **Note:** In most cases, DPoP is managed automatically when `useDPoP: true` is enabled. Manual DPoP APIs are available for advanced use cases where you need direct control over DPoP proof generation. + +📖 **For complete DPoP documentation, examples, and troubleshooting, see [DPOP.md](DPOP.md)** + ### iOS SSO Alert Box ![Screenshot of the SSO alert box](https://user-images.githubusercontent.com/5055789/198689762-8f3459a7-fdde-4c14-a13b-68933ef675e6.png) diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle index 4d6a6b607..4584749d0 100644 --- a/auth0_flutter/android/build.gradle +++ b/auth0_flutter/android/build.gradle @@ -48,6 +48,7 @@ android { sourceSets { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' + test.resources.srcDirs += 'src/test/resources' } defaultConfig { @@ -71,15 +72,15 @@ android { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - //noinspection GradleDynamicVersion - implementation 'com.auth0.android:auth0:2.11.0' - + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation 'com.auth0.android:auth0:3.11.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' - testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" + testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0" + testImplementation "org.mockito:mockito-inline:4.11.0" testImplementation 'com.jayway.awaitility:awaitility:1.7.0' - testImplementation 'org.robolectric:robolectric:4.6.1' + testImplementation 'org.robolectric:robolectric:4.11.1' testImplementation 'androidx.test.espresso:espresso-intents:3.5.1' testImplementation 'com.auth0:java-jwt:3.19.1' -} + +} \ No newline at end of file diff --git a/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties b/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties index 17655d0ef..d951fac2b 100644 --- a/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt index bc932b909..cc1626f67 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt @@ -1,23 +1,33 @@ package com.auth0.auth0_flutter +import android.content.Context import androidx.annotation.NonNull import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.auth0_flutter.request_handlers.api.* +import com.auth0.auth0_flutter.request_handlers.api.ApiRequestHandler import com.auth0.auth0_flutter.request_handlers.MethodCallRequest import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -class Auth0FlutterAuthMethodCallHandler(private val requestHandlers: List) : MethodCallHandler { +class Auth0FlutterAuthMethodCallHandler( + private val apiRequestHandlers: List +) : MethodCallHandler { + lateinit var context: Context + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - val requestHandler = requestHandlers.find { it.method == call.method } - - if (requestHandler != null) { - val request = MethodCallRequest.fromCall(call) + val request = MethodCallRequest.fromCall(call) + + val apiHandler = apiRequestHandlers.find { it.method == call.method } + if (apiHandler != null) { val api = AuthenticationAPIClient(request.account) + + val useDPoP = request.data["useDPoP"] as? Boolean ?: false + if (useDPoP) { + api.useDPoP(context) + } - requestHandler.handle(api, request, result) + apiHandler.handle(api, request, result) } else { result.notImplemented() } diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandler.kt new file mode 100644 index 000000000..3f7b7e4c8 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandler.kt @@ -0,0 +1,30 @@ +package com.auth0.auth0_flutter + +import androidx.annotation.NonNull +import com.auth0.auth0_flutter.request_handlers.api.UtilityRequestHandler +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * Handler for DPoP-related method calls. + * DPoP (Demonstration of Proof-of-Possession) operations use static utility methods + * and don't require authentication API client instances. + */ +class Auth0FlutterDPoPMethodCallHandler( + private val dpopRequestHandlers: List +) : MethodCallHandler { + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + val request = MethodCallRequest.fromCall(call) + + val dpopHandler = dpopRequestHandlers.find { it.method == call.method } + + if (dpopHandler != null) { + dpopHandler.handle(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 0673768c1..24d75c58f 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 @@ -13,7 +13,6 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result /** Auth0FlutterPlugin */ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { @@ -24,23 +23,13 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var webAuthMethodChannel : MethodChannel private lateinit var authMethodChannel : MethodChannel private lateinit var credentialsManagerMethodChannel : MethodChannel + private lateinit var dpopMethodChannel : MethodChannel + private lateinit var binding: FlutterPlugin.FlutterPluginBinding + private lateinit var authCallHandler: Auth0FlutterAuthMethodCallHandler private val webAuthCallHandler = Auth0FlutterWebAuthMethodCallHandler(listOf( - LoginWebAuthRequestHandler { request: MethodCallRequest -> WebAuthProvider.login(request.account) }, + LoginWebAuthRequestHandler(), LogoutWebAuthRequestHandler { request: MethodCallRequest -> WebAuthProvider.logout(request.account) }, )) - private val authCallHandler = Auth0FlutterAuthMethodCallHandler(listOf( - LoginApiRequestHandler(), - LoginWithOtpApiRequestHandler(), - MultifactorChallengeApiRequestHandler(), - EmailPasswordlessApiRequestHandler(), - PhoneNumberPasswordlessApiRequestHandler(), - LoginWithEmailCodeApiRequestHandler(), - LoginWithSMSCodeApiRequestHandler(), - SignupApiRequestHandler(), - UserInfoApiRequestHandler(), - RenewApiRequestHandler(), - ResetPasswordApiRequestHandler() - )) private val credentialsManagerCallHandler = CredentialsManagerMethodCallHandler(listOf( GetCredentialsRequestHandler(), RenewCredentialsRequestHandler(), @@ -48,33 +37,59 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { HasValidCredentialsRequestHandler(), ClearCredentialsRequestHandler() )) + private val dpopCallHandler = Auth0FlutterDPoPMethodCallHandler(listOf( + GetDPoPHeadersApiRequestHandler(), + ClearDPoPKeyApiRequestHandler() + )) override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - webAuthMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "auth0.com/auth0_flutter/web_auth") - webAuthMethodChannel.setMethodCallHandler(webAuthCallHandler) + binding = flutterPluginBinding + val messenger = binding.binaryMessenger + val context = binding.applicationContext - authMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "auth0.com/auth0_flutter/auth") - authMethodChannel.setMethodCallHandler(authCallHandler) + webAuthMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/web_auth") + webAuthMethodChannel.setMethodCallHandler(webAuthCallHandler) - credentialsManagerMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "auth0.com/auth0_flutter/credentials_manager") + credentialsManagerMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/credentials_manager") credentialsManagerMethodChannel.setMethodCallHandler(credentialsManagerCallHandler) + credentialsManagerCallHandler.context = context - credentialsManagerCallHandler.context = flutterPluginBinding.applicationContext + authCallHandler = Auth0FlutterAuthMethodCallHandler( + listOf( + LoginApiRequestHandler(), + LoginWithOtpApiRequestHandler(), + MultifactorChallengeApiRequestHandler(), + EmailPasswordlessApiRequestHandler(), + PhoneNumberPasswordlessApiRequestHandler(), + LoginWithEmailCodeApiRequestHandler(), + LoginWithSMSCodeApiRequestHandler(), + SignupApiRequestHandler(), + UserInfoApiRequestHandler(), + RenewApiRequestHandler(), + ResetPasswordApiRequestHandler() + ) + ) + authCallHandler.context = context + + authMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/auth") + authMethodChannel.setMethodCallHandler(authCallHandler) + + dpopMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/dpop") + dpopMethodChannel.setMethodCallHandler(dpopCallHandler) } - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {} + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) {} override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { webAuthMethodChannel.setMethodCallHandler(null) authMethodChannel.setMethodCallHandler(null) credentialsManagerMethodChannel.setMethodCallHandler(null) + dpopMethodChannel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { webAuthCallHandler.activity = binding.activity credentialsManagerCallHandler.activity = binding.activity - - binding.addActivityResultListener(credentialsManagerCallHandler) } override fun onDetachedFromActivityForConfigChanges() { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt index d2a36d726..3b153f8f7 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt @@ -2,68 +2,131 @@ package com.auth0.auth0_flutter import android.app.Activity import android.content.Context -import android.content.Intent import androidx.annotation.NonNull +import androidx.fragment.app.FragmentActivity +import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.storage.AuthenticationLevel +import com.auth0.android.authentication.storage.LocalAuthenticationOptions import com.auth0.android.authentication.storage.SecureCredentialsManager import com.auth0.android.authentication.storage.SharedPreferencesStorage import com.auth0.auth0_flutter.request_handlers.MethodCallRequest import com.auth0.auth0_flutter.request_handlers.credentials_manager.CredentialsManagerRequestHandler -import com.auth0.auth0_flutter.utils.RequestCodes import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.PluginRegistry -class CredentialsManagerMethodCallHandler(private val requestHandlers: List) : - MethodCallHandler, PluginRegistry.ActivityResultListener { +/** + * Constants for mapping biometric authentication levels between Dart and Kotlin. + * These values must match the BiometricAuthenticationLevel enum in lib/src/credentials_manager.dart + */ +private object BiometricAuthLevel { + const val STRONG = 0 + const val WEAK = 1 + const val DEVICE_CREDENTIAL = 2 +} + +class CredentialsManagerMethodCallHandler(private val requestHandlers: List) : MethodCallHandler { lateinit var activity: Activity lateinit var context: Context - var credentialsManager: SecureCredentialsManager? = null + private data class ManagerCacheKey( + val accountDomain: String, + val accountClientId: String, + val sharedPreferenceName: String?, + val useDPoP: Boolean, + val hasLocalAuth: Boolean + ) + + private var cachedManager: SecureCredentialsManager? = null + private var cachedKey: ManagerCacheKey? = null + + private fun buildLocalAuthenticationOptions(localAuthentication: Map): LocalAuthenticationOptions { + val builder = LocalAuthenticationOptions.Builder() + (localAuthentication["title"] as String?)?.let { builder.setTitle(it) } + (localAuthentication["description"] as String?)?.let { builder.setDescription(it) } + (localAuthentication["cancelTitle"] as String?)?.let { builder.setNegativeButtonText(it) } + + val authenticationLevel = localAuthentication["authenticationLevel"] as Int? + when (authenticationLevel) { + BiometricAuthLevel.STRONG -> builder.setAuthenticationLevel(AuthenticationLevel.STRONG) + BiometricAuthLevel.WEAK -> builder.setAuthenticationLevel(AuthenticationLevel.WEAK) + BiometricAuthLevel.DEVICE_CREDENTIAL -> builder.setAuthenticationLevel(AuthenticationLevel.DEVICE_CREDENTIAL) + else -> builder.setAuthenticationLevel(AuthenticationLevel.STRONG) + } + builder.setDeviceCredentialFallback(true) + + return builder.build() + } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { val requestHandler = requestHandlers.find { it.method == call.method } if (requestHandler != null) { val request = MethodCallRequest.fromCall(call) + val activity = this.activity - val configuration = - request.data["credentialsManagerConfiguration"] as Map<*, *>? + val configuration = request.data["credentialsManagerConfiguration"] as Map<*, *>? val sharedPreferenceConfiguration = configuration?.get("android") val sharedPreferenceName: String? = if (sharedPreferenceConfiguration != null) { - (sharedPreferenceConfiguration as Map).get("sharedPreferencesName") + (sharedPreferenceConfiguration as Map)["sharedPreferencesName"] } else null - val api = AuthenticationAPIClient(request.account) val storage = sharedPreferenceName?.let { SharedPreferencesStorage(context, it) } ?: SharedPreferencesStorage(context) - credentialsManager = - credentialsManager ?: SecureCredentialsManager(context, api, storage) - - val credentialsManager = credentialsManager as SecureCredentialsManager - val localAuthentication = - request.data.get("localAuthentication") as Map? - - if (localAuthentication != null) { - val title = localAuthentication["title"] - val description = localAuthentication["description"] - credentialsManager.requireAuthentication( - activity, - RequestCodes.AUTH_REQ_CODE, - title, - description - ) + + val localAuthentication = request.data["localAuthentication"] as Map? + val useDPoP = request.data["useDPoP"] as? Boolean ?: false + + val currentKey = ManagerCacheKey( + accountDomain = request.account.domain, + accountClientId = request.account.clientId, + sharedPreferenceName = sharedPreferenceName, + useDPoP = useDPoP, + hasLocalAuth = localAuthentication != null + ) + + val credentialsManagerInstance: SecureCredentialsManager = if (cachedKey == currentKey && cachedManager != null) { + cachedManager!! + } else { + if (localAuthentication != null && activity !is FragmentActivity) { + result.error( + "credentialsManager#fragment-activity-required", + "The Activity must extend FlutterFragmentActivity (not FlutterActivity) for biometric authentication. " + + "Update your MainActivity.kt to extend FlutterFragmentActivity. " + + "See: https://developer.android.com/reference/androidx/fragment/app/FragmentActivity", + null + ) + return + } + + val options = localAuthentication?.let { buildLocalAuthenticationOptions(it) } + + val apiClient = AuthenticationAPIClient(request.account) + if (useDPoP) { + apiClient.useDPoP(context) + } + + val newManager = if (options != null) { + SecureCredentialsManager( + apiClient, context, request.account, storage, activity as FragmentActivity, options + ) + } else { + SecureCredentialsManager( + apiClient, context, request.account, storage + ) + } + + cachedManager = newManager + cachedKey = currentKey + newManager } - requestHandler.handle(credentialsManager, context, request, result) + + requestHandler.handle(credentialsManagerInstance, context, request, result) } else { result.notImplemented() } } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - return credentialsManager?.checkAuthenticationResult(requestCode, resultCode) ?: true - } -} +} \ No newline at end of file diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/MethodCallRequest.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/MethodCallRequest.kt index a038569b2..262158dee 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/MethodCallRequest.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/MethodCallRequest.kt @@ -31,7 +31,7 @@ class MethodCallRequest { ) val accountMap = args["_account"] as Map - val account = Auth0( + val account = Auth0.getInstance( accountMap["clientId"] as String, accountMap["domain"] as String ) @@ -45,4 +45,4 @@ class MethodCallRequest { return MethodCallRequest(account, args) } } -} +} \ No newline at end of file diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/ClearDPoPKeyApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/ClearDPoPKeyApiRequestHandler.kt new file mode 100644 index 000000000..59b862d6a --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/ClearDPoPKeyApiRequestHandler.kt @@ -0,0 +1,40 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel + +private const val AUTH_CLEAR_DPOP_KEY_METHOD = "dpop#clearDPoPKey" + +/** + * Handles clearDPoPKey method call. Uses the DPoP utility class directly + * to clear the stored DPoP key pair, matching the approach used in React Native Auth0. + */ +class ClearDPoPKeyApiRequestHandler : UtilityRequestHandler { + override val method: String = AUTH_CLEAR_DPOP_KEY_METHOD + + override fun handle( + request: MethodCallRequest, + result: MethodChannel.Result + ) { + try { + DPoP.clearKeyPair() + result.success(null) + } catch (e: DPoPException) { + result.error( + "DPOP_ERROR", + e.message ?: "Failed to clear DPoP key", + mapOf( + "errorType" to e.javaClass.simpleName + ) + ) + } catch (e: Exception) { + result.error( + "CLEAR_DPOP_KEY_ERROR", + e.message ?: "Failed to clear DPoP key", + null + ) + } + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/GetDPoPHeadersApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/GetDPoPHeadersApiRequestHandler.kt new file mode 100644 index 000000000..653e9f1eb --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/GetDPoPHeadersApiRequestHandler.kt @@ -0,0 +1,74 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel + +private const val AUTH_GET_DPOP_HEADERS_METHOD = "dpop#getDPoPHeaders" + +/** + * Handles getDPoPHeaders method call. Uses the DPoP utility class directly + * to generate DPoP proof headers, matching the approach used in React Native Auth0. + */ +class GetDPoPHeadersApiRequestHandler : UtilityRequestHandler { + override val method: String = AUTH_GET_DPOP_HEADERS_METHOD + + override fun handle( + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val url = request.data["url"] as? String + val httpMethod = request.data["method"] as? String + val accessToken = request.data["accessToken"] as? String + val tokenType = request.data["tokenType"] as? String + val nonce = request.data["nonce"] as? String + + if (url == null || httpMethod == null || accessToken == null || tokenType == null) { + result.error( + "INVALID_ARGUMENTS", + "url, method, accessToken, and tokenType are required", + null + ) + return + } + + try { + if (!tokenType.equals("DPoP", ignoreCase = true)) { + val headers = mutableMapOf() + headers["authorization"] = "Bearer $accessToken" + result.success(headers) + return + } + + val headerData = if (!nonce.isNullOrEmpty()) { + DPoP.getHeaderData(httpMethod, url, accessToken, tokenType, nonce) + } else { + DPoP.getHeaderData(httpMethod, url, accessToken, tokenType) + } + + val resultMap = mutableMapOf() + resultMap["authorization"] = headerData.authorizationHeader + + headerData.dpopProof?.let { proof -> + resultMap["dpop"] = proof + } + + result.success(resultMap) + } catch (e: DPoPException) { + result.error( + "DPOP_ERROR", + e.message ?: "Failed to generate DPoP headers", + mapOf( + "errorType" to e.javaClass.simpleName + ) + ) + } catch (e: Exception) { + result.error( + "GET_DPOP_HEADERS_ERROR", + e.message ?: "Failed to generate DPoP headers", + null + ) + } + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandler.kt index 77203ecb2..e3010cb94 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandler.kt @@ -21,7 +21,9 @@ class UserInfoApiRequestHandler : ApiRequestHandler { ) { assertHasProperties(listOf("accessToken"), request.data) - val builder = api.userInfo(request.data["accessToken"] as String) + val accessToken = request.data["accessToken"] as String + val tokenType = request.data["tokenType"] as? String ?: "Bearer" + val builder = api.userInfo(accessToken, tokenType) if (request.data["parameters"] is HashMap<*, *>) { builder.addParameters(request.data["parameters"] as Map) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UtilityRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UtilityRequestHandler.kt new file mode 100644 index 000000000..97c925802 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/UtilityRequestHandler.kt @@ -0,0 +1,12 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel + +/** + * Interface for request handlers for utility operations like DPoP key management that use static methods. + */ +interface UtilityRequestHandler { + val method: String + fun handle(request: MethodCallRequest, result: MethodChannel.Result) +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt index a97fcd880..e21e983e5 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt @@ -12,7 +12,9 @@ import com.auth0.auth0_flutter.toMap import io.flutter.plugin.common.MethodChannel import java.util.* -class LoginWebAuthRequestHandler(private val builderResolver: (MethodCallRequest) -> WebAuthProvider.Builder) : WebAuthRequestHandler { +class LoginWebAuthRequestHandler( + private val builderProvider: (com.auth0.android.Auth0) -> WebAuthProvider.Builder = { account -> WebAuthProvider.login(account) } +) : WebAuthRequestHandler { override val method: String = "webAuth#login" override fun handle( @@ -20,7 +22,7 @@ class LoginWebAuthRequestHandler(private val builderResolver: (MethodCallRequest request: MethodCallRequest, result: MethodChannel.Result ) { - val builder = builderResolver(request) + val builder = builderProvider(request.account) val args = request.data val scopes = (args["scopes"] ?: arrayListOf()) as ArrayList<*> @@ -72,13 +74,16 @@ class LoginWebAuthRequestHandler(private val builderResolver: (MethodCallRequest builder.withParameters(args["parameters"] as Map) } + if (args["useDPoP"] == true) { + WebAuthProvider.useDPoP(context) + } + builder.start(context, object : Callback { override fun onFailure(exception: AuthenticationException) { result.error(exception.getCode(), exception.getDescription(), exception) } override fun onSuccess(credentials: Credentials) { - // Success! Access token and ID token are presents val scopes = credentials.scope?.split(" ") ?: listOf() val formattedDate = credentials.expiresAt.toInstant().toString() diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandlerTest.kt index 693749164..228ef28fa 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandlerTest.kt @@ -1,5 +1,7 @@ package com.auth0.auth0_flutter +import android.content.Context +import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.auth0_flutter.request_handlers.api.ApiRequestHandler import com.auth0.auth0_flutter.request_handlers.api.LoginApiRequestHandler import com.auth0.auth0_flutter.request_handlers.api.SignupApiRequestHandler @@ -10,6 +12,7 @@ import org.junit.runner.RunWith import org.mockito.Mockito.`when` import org.mockito.kotlin.* import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class Auth0FlutterAuthMethodCallHandlerTest { @@ -27,10 +30,10 @@ class Auth0FlutterAuthMethodCallHandlerTest { private fun runCallHandler( method: String, arguments: HashMap = defaultArguments, - requestHandlers: List, + apiRequestHandlers: List = emptyList(), onResult: (Result) -> Unit ) { - val handler = Auth0FlutterAuthMethodCallHandler(requestHandlers) + val handler = Auth0FlutterAuthMethodCallHandler(apiRequestHandlers) val mockResult = mock() handler.onMethodCall(MethodCall(method, arguments), mockResult) @@ -39,7 +42,7 @@ class Auth0FlutterAuthMethodCallHandlerTest { @Test fun `handler should result in 'notImplemented' if no handlers`() { - runCallHandler("auth#login", requestHandlers = listOf()) { result -> + runCallHandler("auth#login") { result -> verify(result).notImplemented() } } @@ -50,7 +53,7 @@ class Auth0FlutterAuthMethodCallHandlerTest { `when`(signupHandler.method).thenReturn("auth#signup") - runCallHandler("auth#login", requestHandlers = listOf(signupHandler)) { result -> + runCallHandler("auth#login", apiRequestHandlers = listOf(signupHandler)) { result -> verify(result).notImplemented() } } @@ -63,7 +66,7 @@ class Auth0FlutterAuthMethodCallHandlerTest { `when`(loginHandler.method).thenReturn("auth#login") `when`(signupHandler.method).thenReturn("auth#signup") - runCallHandler(loginHandler.method, requestHandlers = listOf(loginHandler, signupHandler)) { + runCallHandler(loginHandler.method, apiRequestHandlers = listOf(loginHandler, signupHandler)) { verify(loginHandler).handle(any(), any(), any()) verify(signupHandler, times(0)).handle(any(), any(), any()) } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandlerTest.kt new file mode 100644 index 000000000..47e5eb700 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterDPoPMethodCallHandlerTest.kt @@ -0,0 +1,71 @@ +package com.auth0.auth0_flutter + +import com.auth0.auth0_flutter.request_handlers.api.UtilityRequestHandler +import com.auth0.auth0_flutter.request_handlers.api.GetDPoPHeadersApiRequestHandler +import com.auth0.auth0_flutter.request_handlers.api.ClearDPoPKeyApiRequestHandler +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 Auth0FlutterDPoPMethodCallHandlerTest { + 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" + ) + ) + + private fun runCallHandler( + method: String, + arguments: HashMap = defaultArguments, + dpopRequestHandlers: List = emptyList(), + onResult: (Result) -> Unit + ) { + val handler = Auth0FlutterDPoPMethodCallHandler(dpopRequestHandlers) + val mockResult = mock() + + handler.onMethodCall(MethodCall(method, arguments), mockResult) + onResult(mockResult) + } + + @Test + fun `handler should result in 'notImplemented' if no handlers`() { + runCallHandler("dpop#getDPoPHeaders") { result -> + verify(result).notImplemented() + } + } + + @Test + fun `handler should result in 'notImplemented' if no matching handler`() { + val clearHandler = mock() + + `when`(clearHandler.method).thenReturn("dpop#clearDPoPKey") + + runCallHandler("dpop#getDPoPHeaders", dpopRequestHandlers = listOf(clearHandler)) { result -> + verify(result).notImplemented() + } + } + + @Test + fun `handler should only run the correct handler`() { + val getDPoPHeadersHandler = mock() + val clearDPoPKeyHandler = mock() + + `when`(getDPoPHeadersHandler.method).thenReturn("dpop#getDPoPHeaders") + `when`(clearDPoPKeyHandler.method).thenReturn("dpop#clearDPoPKey") + + runCallHandler(getDPoPHeadersHandler.method, dpopRequestHandlers = listOf(getDPoPHeadersHandler, clearDPoPKeyHandler)) { + verify(getDPoPHeadersHandler).handle(any(), any()) + verify(clearDPoPKeyHandler, times(0)).handle(any(), any()) + } + } +} 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 5c095f892..d1f845489 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 @@ -21,7 +21,9 @@ class Auth0FlutterPluginTest { val mockBindings = mock() val mockContext = mock() + val mockMessenger = mock() `when`(mockBindings.applicationContext).thenReturn(mockContext) + `when`(mockBindings.binaryMessenger).thenReturn(mockMessenger) plugin.onAttachedToEngine(mockBindings) val constructed: List = m.constructed() @@ -32,11 +34,12 @@ class Auth0FlutterPluginTest { ) } - assertMethodcallHandler(0) + assertMethodcallHandler(0) assertMethodcallHandler(1) assertMethodcallHandler(2) + assertMethodcallHandler(3) - assert(constructed.size == 3) + assert(constructed.size == 4) } } @@ -47,7 +50,9 @@ class Auth0FlutterPluginTest { val mockBindings = mock() val mockContext = mock() + val mockMessenger = mock() `when`(mockBindings.applicationContext).thenReturn(mockContext) + `when`(mockBindings.binaryMessenger).thenReturn(mockMessenger) plugin.onAttachedToEngine(mockBindings) val constructed: List = m.constructed() @@ -63,8 +68,9 @@ class Auth0FlutterPluginTest { assertMethodcallHandler(0) assertMethodcallHandler(1) assertMethodcallHandler(2) + assertMethodcallHandler(3) - assert(constructed.size == 3) + assert(constructed.size == 4) } } @@ -75,7 +81,9 @@ class Auth0FlutterPluginTest { val mockBindings = mock() val mockContext = mock() + val mockMessenger = mock() `when`(mockBindings.applicationContext).thenReturn(mockContext) + `when`(mockBindings.binaryMessenger).thenReturn(mockMessenger) plugin.onAttachedToEngine(mockBindings) val constructed: List = m.constructed() @@ -96,33 +104,10 @@ class Auth0FlutterPluginTest { } assert(getHandler(0).activity == mockActivity) - assert(getHandler(2).activity == mockActivity) - assert(getHandler(2).context == mockContext) + assert(getHandler(1).activity == mockActivity) + assert(getHandler(1).context == mockContext) - assert(constructed.size == 3) - } - } - - @Test - fun `should call binding addActivityResultListener for CredentialsManager on onAttachedToActivity`() { - mockConstruction(MethodChannel::class.java).use { - val plugin = Auth0FlutterPlugin() - - val mockBindings = mock() - val mockContext = mock() - `when`(mockBindings.applicationContext).thenReturn(mockContext) - plugin.onAttachedToEngine(mockBindings) - - val mockActivityBindings = mock() - val mockActivity = mock() - - `when`(mockActivityBindings.activity).thenReturn(mockActivity) - - plugin.onAttachedToActivity(mockActivityBindings) - - verify(mockActivityBindings).addActivityResultListener( - any() - ) + assert(constructed.size == 4) } } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt index fbcd4fbb9..5f413dc62 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt @@ -65,8 +65,9 @@ class CredentialsManagerMethodCallHandlerTest { } } + @Test - fun `handler should not call credentialsManager requireAuthentication`() { + fun `handler should extract sharedPreferenceName correctly`() { val clearCredentialsHandler = mock() `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") @@ -78,131 +79,387 @@ class CredentialsManagerMethodCallHandlerTest { `when`(context.getSharedPreferences(any(), any())) .thenReturn(mockPrefs) + val arguments = defaultArguments + hashMapOf( + "credentialsManagerConfiguration" to mapOf( + "android" to mapOf("sharedPreferencesName" to "test_prefs") + ) + ) + + runCallHandler(clearCredentialsHandler.method, arguments as HashMap, listOf(clearCredentialsHandler), activity, context) { + verify(context).getSharedPreferences(eq("test_prefs"), any()) + } + } + + @Test + fun `handler should only run the correct handler`() { + val clearCredentialsHandler = mock() + val hasValidCredentialsHandler = mock() + + `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") + `when`(hasValidCredentialsHandler.method).thenReturn("credentialsManager#hasValidCredentials") + + val activity: Activity = mock() + val context: Context = mock() + val mockPrefs: SharedPreferences = mock() + + `when`(context.getSharedPreferences(any(), any())) + .thenReturn(mockPrefs) + + runCallHandler(clearCredentialsHandler.method, activity = activity, context = context, requestHandlers = listOf(clearCredentialsHandler, hasValidCredentialsHandler)) { + verify(clearCredentialsHandler).handle(any(), eq(context), any(), any()) + verify(hasValidCredentialsHandler, times(0)).handle(any(), eq(context), any(), any()) + } + } + + @Test + fun `handler should reuse cached manager when configuration is identical`() { + val clearCredentialsHandler = mock() + `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") + val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler)) val mockResult = mock() + val activity: Activity = mock() + val context: Context = mock() + val mockPrefs: SharedPreferences = mock() + + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) handler.activity = activity handler.context = context - handler.credentialsManager = mock() - handler.onMethodCall(MethodCall(clearCredentialsHandler.method, defaultArguments), mockResult) + val arguments = defaultArguments.toMutableMap() + val call1 = MethodCall("credentialsManager#clearCredentials", arguments) + val call2 = MethodCall("credentialsManager#clearCredentials", arguments) - verify(handler.credentialsManager, never())?.requireAuthentication(any(), any(), any(), any()) + handler.onMethodCall(call1, mockResult) + verify(clearCredentialsHandler, times(1)).handle(any(), eq(context), any(), any()) + + handler.onMethodCall(call2, mockResult) + verify(clearCredentialsHandler, times(2)).handle(any(), eq(context), any(), any()) + + val managerCaptor = argumentCaptor() + verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) + + MatcherAssert.assertThat( + "Manager should be reused when configuration is identical", + managerCaptor.firstValue, + CoreMatchers.sameInstance(managerCaptor.secondValue) + ) } @Test - fun `handler should extract sharedPreferenceName correctly`() { + fun `handler should create new manager when domain changes`() { val clearCredentialsHandler = mock() - `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") + val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler)) + val mockResult = mock() val activity: Activity = mock() val context: Context = mock() val mockPrefs: SharedPreferences = mock() - `when`(context.getSharedPreferences(any(), any())) - .thenReturn(mockPrefs) + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) - val arguments = defaultArguments + hashMapOf( - "credentialsManagerConfiguration" to mapOf( - "android" to mapOf("sharedPreferencesName" to "test_prefs") + handler.activity = activity + handler.context = context + + val arguments1 = hashMapOf( + "_account" to mapOf( + "domain" to "test1.auth0.com", + "clientId" to "test-client", + ), + "_userAgent" to mapOf( + "name" to "auth0-flutter", + "version" to "1.0.0" + ) + ) + val call1 = MethodCall("credentialsManager#clearCredentials", arguments1) + handler.onMethodCall(call1, mockResult) + + val arguments2 = hashMapOf( + "_account" to mapOf( + "domain" to "test2.auth0.com", + "clientId" to "test-client", + ), + "_userAgent" to mapOf( + "name" to "auth0-flutter", + "version" to "1.0.0" ) ) + val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) + handler.onMethodCall(call2, mockResult) - runCallHandler(clearCredentialsHandler.method, arguments as HashMap, listOf(clearCredentialsHandler), activity, context) { - verify(context).getSharedPreferences(eq("test_prefs"), any()) - } + verify(clearCredentialsHandler, times(2)).handle(any(), eq(context), any(), any()) + + val managerCaptor = argumentCaptor() + verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) + + MatcherAssert.assertThat( + "New manager should be created when domain changes", + managerCaptor.firstValue, + CoreMatchers.not(CoreMatchers.sameInstance(managerCaptor.secondValue)) + ) } @Test - fun `handler should call credentialsManager requireAuthentication`() { + fun `handler should create new manager when clientId changes`() { val clearCredentialsHandler = mock() - `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") + val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler)) + val mockResult = mock() val activity: Activity = mock() val context: Context = mock() val mockPrefs: SharedPreferences = mock() - `when`(context.getSharedPreferences(any(), any())) - .thenReturn(mockPrefs) + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) + + handler.activity = activity + handler.context = context + + val arguments1 = hashMapOf( + "_account" to mapOf( + "domain" to "test.auth0.com", + "clientId" to "client-1", + ), + "_userAgent" to mapOf( + "name" to "auth0-flutter", + "version" to "1.0.0" + ) + ) + val call1 = MethodCall("credentialsManager#clearCredentials", arguments1) + handler.onMethodCall(call1, mockResult) + + val arguments2 = hashMapOf( + "_account" to mapOf( + "domain" to "test.auth0.com", + "clientId" to "client-2", + ), + "_userAgent" to mapOf( + "name" to "auth0-flutter", + "version" to "1.0.0" + ) + ) + val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) + handler.onMethodCall(call2, mockResult) + + val managerCaptor = argumentCaptor() + verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) + + MatcherAssert.assertThat( + "New manager should be created when clientId changes", + managerCaptor.firstValue, + CoreMatchers.not(CoreMatchers.sameInstance(managerCaptor.secondValue)) + ) + } + + @Test + fun `handler should create new manager when sharedPreferencesName changes`() { + val clearCredentialsHandler = mock() + `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler)) val mockResult = mock() + val activity: Activity = mock() + val context: Context = mock() + val mockPrefs: SharedPreferences = mock() + + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) handler.activity = activity handler.context = context - handler.credentialsManager = mock() - - handler.onMethodCall(MethodCall(clearCredentialsHandler.method, defaultArguments + hashMapOf("localAuthentication" to hashMapOf("title" to "test", "description" to "test description"))), mockResult) + val arguments1 = 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" + ), + "credentialsManagerConfiguration" to mapOf( + "android" to mapOf("sharedPreferencesName" to "prefs_1") + ) + ) + val call1 = MethodCall("credentialsManager#clearCredentials", arguments1) + handler.onMethodCall(call1, mockResult) + + val arguments2 = 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" + ), + "credentialsManagerConfiguration" to mapOf( + "android" to mapOf("sharedPreferencesName" to "prefs_2") + ) + ) + val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) + handler.onMethodCall(call2, mockResult) - verify(handler.credentialsManager)?.requireAuthentication(eq(activity), eq(111), eq("test"), eq("test description")) + val managerCaptor = argumentCaptor() + verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) + + MatcherAssert.assertThat( + "New manager should be created when sharedPreferencesName changes", + managerCaptor.firstValue, + CoreMatchers.not(CoreMatchers.sameInstance(managerCaptor.secondValue)) + ) } @Test - fun `handler should call credentialsManager requireAuthentication with default values`() { + fun `handler should create new manager when useDPoP flag changes`() { val clearCredentialsHandler = mock() - `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") + val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler)) + val mockResult = mock() val activity: Activity = mock() val context: Context = mock() val mockPrefs: SharedPreferences = mock() - `when`(context.getSharedPreferences(any(), any())) - .thenReturn(mockPrefs) + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) + + handler.activity = activity + handler.context = context + + val arguments1 = 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" + ), + "useDPoP" to false + ) + val call1 = MethodCall("credentialsManager#clearCredentials", arguments1) + handler.onMethodCall(call1, mockResult) + + val arguments2 = 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" + ), + "useDPoP" to true + ) + val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) + handler.onMethodCall(call2, mockResult) + + val managerCaptor = argumentCaptor() + verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) + + MatcherAssert.assertThat( + "New manager should be created when useDPoP flag changes", + managerCaptor.firstValue, + CoreMatchers.not(CoreMatchers.sameInstance(managerCaptor.secondValue)) + ) + } + + @Test + fun `handler should create new manager when localAuthentication changes`() { + val clearCredentialsHandler = mock() + `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler)) val mockResult = mock() + val activity: androidx.fragment.app.FragmentActivity = mock() + val context: Context = mock() + val mockPrefs: SharedPreferences = mock() + + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) handler.activity = activity handler.context = context - handler.credentialsManager = mock() - handler.onMethodCall(MethodCall(clearCredentialsHandler.method, defaultArguments + hashMapOf("localAuthentication" to hashMapOf())), mockResult) + val arguments1 = 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" + ) + ) + val call1 = MethodCall("credentialsManager#clearCredentials", arguments1) + handler.onMethodCall(call1, mockResult) + + val arguments2 = 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" + ), + "localAuthentication" to mapOf( + "title" to "Authenticate", + "description" to "Biometric auth required", + "cancelTitle" to "Cancel", + "authenticationLevel" to 0 + ) + ) + val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) + handler.onMethodCall(call2, mockResult) - verify(handler.credentialsManager)?.requireAuthentication(eq(activity), eq(111), isNull(), isNull()) + val managerCaptor = argumentCaptor() + verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) + + MatcherAssert.assertThat( + "New manager should be created when localAuthentication changes", + managerCaptor.firstValue, + CoreMatchers.not(CoreMatchers.sameInstance(managerCaptor.secondValue)) + ) } @Test - fun `handler should only run the correct handler`() { + fun `handler should reuse manager across different method calls with same configuration`() { val clearCredentialsHandler = mock() val hasValidCredentialsHandler = mock() - + `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") `when`(hasValidCredentialsHandler.method).thenReturn("credentialsManager#hasValidCredentials") + val handler = CredentialsManagerMethodCallHandler(listOf(clearCredentialsHandler, hasValidCredentialsHandler)) + val mockResult = mock() val activity: Activity = mock() val context: Context = mock() val mockPrefs: SharedPreferences = mock() - `when`(context.getSharedPreferences(any(), any())) - .thenReturn(mockPrefs) - - runCallHandler(clearCredentialsHandler.method, activity = activity, context = context, requestHandlers = listOf(clearCredentialsHandler, hasValidCredentialsHandler)) { - verify(clearCredentialsHandler).handle(any(), eq(context), any(), any()) - verify(hasValidCredentialsHandler, times(0)).handle(any(), eq(context), any(), any()) - } - } + `when`(context.getSharedPreferences(any(), any())).thenReturn(mockPrefs) - @Test - fun `should call checkAuthenticationResult in onActivityResult`() { - val handler = CredentialsManagerMethodCallHandler(listOf()) + handler.activity = activity + handler.context = context - handler.credentialsManager = mock() - handler.onActivityResult(1, 2, null) + val arguments = defaultArguments.toMutableMap() - verify(handler.credentialsManager)?.checkAuthenticationResult(1, 2) - } + val call1 = MethodCall("credentialsManager#clearCredentials", arguments) + handler.onMethodCall(call1, mockResult) - @Test - fun `should return true in onActivityResult when no credentialsManager`() { - val handler = CredentialsManagerMethodCallHandler(listOf()) - val result = handler.onActivityResult(1, 2, null) + val call2 = MethodCall("credentialsManager#hasValidCredentials", arguments) + handler.onMethodCall(call2, mockResult) + val clearManagerCaptor = argumentCaptor() + val hasValidManagerCaptor = argumentCaptor() + + verify(clearCredentialsHandler).handle(clearManagerCaptor.capture(), any(), any(), any()) + verify(hasValidCredentialsHandler).handle(hasValidManagerCaptor.capture(), any(), any(), any()) + MatcherAssert.assertThat( - result, - CoreMatchers.equalTo(true) + "Same manager should be reused across different method calls with identical configuration", + clearManagerCaptor.firstValue, + CoreMatchers.sameInstance(hasValidManagerCaptor.firstValue) ) } } + diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt index 7d19d9965..f24d6753e 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt @@ -41,8 +41,8 @@ class LoginWebAuthRequestHandlerTest { callback(mockResult, builder) }.`when`(builder).start(any(), any()) - val handler = LoginWebAuthRequestHandler { builder } - val request = MethodCallRequest(Auth0("test.auth0.com", "test-client"), args) + val handler = LoginWebAuthRequestHandler { _ -> builder } + val request = MethodCallRequest(Auth0.getInstance("test-client", "test.auth0.com"), args) handler.handle(mock(), request, mockResult) } @@ -50,10 +50,7 @@ class LoginWebAuthRequestHandlerTest { @Test fun `handler should log in using the Auth0 SDK`() { runRequestHandler { result, builder -> - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(defaultCredentials.expiresAt) + val formattedDate = defaultCredentials.expiresAt.toInstant().toString() verify(result).success(check { val map = it as Map<*, *> @@ -302,9 +299,11 @@ class LoginWebAuthRequestHandlerTest { cb.onFailure(exception) }.`when`(builder).start(any(), any()) - val handler = LoginWebAuthRequestHandler { builder } + val handler = LoginWebAuthRequestHandler { _ -> builder } - handler.handle(mock(), mock(), mockResult) + val mockAccount = mock() + val mockRequest = MethodCallRequest(mockAccount, hashMapOf()) + handler.handle(mock(), mockRequest, mockResult) verify(mockResult).error("code", "description", exception) } @@ -323,17 +322,16 @@ class LoginWebAuthRequestHandlerTest { cb.onSuccess(credentials) }.`when`(builder).start(any(), any()) - val handler = LoginWebAuthRequestHandler { builder } + val handler = LoginWebAuthRequestHandler { _ -> builder } - handler.handle(mock(), mock(), mockResult) + val mockAccount = mock() + val mockRequest = MethodCallRequest(mockAccount, hashMapOf()) + handler.handle(mock(), mockRequest, mockResult) val captor = argumentCaptor<() -> Map>() verify(mockResult).success(captor.capture()) - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(credentials.expiresAt) + val formattedDate = credentials.expiresAt.toInstant().toString() assertThat((captor.firstValue as Map<*, *>)["accessToken"], equalTo(credentials.accessToken)) assertThat((captor.firstValue as Map<*, *>)["idToken"], equalTo(credentials.idToken)) diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt index 6155db5b3..d3c0f445e 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt @@ -52,7 +52,7 @@ class LogoutWebAuthRequestHandlerTest { resultCallback(mockResult, mockBuilder) }.`when`(mockBuilder).start(any(), any()) - handler.handle(mock(), MethodCallRequest(Auth0("test-domain", "test-client"), args), mockResult) + handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), args), mockResult) } @Test @@ -118,7 +118,7 @@ class LogoutWebAuthRequestHandlerTest { callback.onFailure(exception) }.`when`(mockBuilder).start(any(), any()) - handler.handle(mock(), MethodCallRequest(Auth0("test-domain", "test-client"), mock()), mockResult) + handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), mock()), mockResult) verify(mockResult).error("code", "description", exception) } @@ -134,7 +134,7 @@ class LogoutWebAuthRequestHandlerTest { callback.onSuccess(null) }.`when`(mockBuilder).start(any(), any()) - handler.handle(mock(), MethodCallRequest(Auth0("test-domain", "test-client"), mock()), mockResult) + handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), mock()), mockResult) verify(mockResult).success(null) } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginApiRequestHandlerTest.kt index e85ed0d3d..39f4462d9 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginApiRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginApiRequestHandlerTest.kt @@ -331,12 +331,12 @@ class LoginApiRequestHandlerTest { val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe")) val credentials = Credentials(idToken, "test", "", null, Date(), "scope1 scope2") - doReturn(mockLoginBuilder).`when`(mockApi).login(any(), any(), any()) - doReturn(mockLoginBuilder).`when`(mockLoginBuilder).addParameters(any()) - doAnswer { - val ob = it.getArgument>(0) - ob.onSuccess(credentials) - }.`when`(mockLoginBuilder).start(any()) + whenever(mockApi.login(any(), any(), any())).thenReturn(mockLoginBuilder) + whenever(mockLoginBuilder.addParameters(any())).thenReturn(mockLoginBuilder) + whenever(mockLoginBuilder.start(any())).thenAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(credentials) + } handler.handle( mockApi, @@ -344,19 +344,18 @@ class LoginApiRequestHandlerTest { mockResult ) - val captor = argumentCaptor<() -> Map>() + val captor = argumentCaptor>() verify(mockResult).success(captor.capture()) - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(credentials.expiresAt) + val formattedDate = credentials.expiresAt.toInstant().toString() + + val resultMap = captor.firstValue - assertThat((captor.firstValue as Map<*, *>)["accessToken"], equalTo(credentials.accessToken)) - assertThat((captor.firstValue as Map<*, *>)["idToken"], equalTo(credentials.idToken)) - assertThat((captor.firstValue as Map<*, *>)["refreshToken"], equalTo(credentials.refreshToken)) - assertThat((captor.firstValue as Map<*, *>)["expiresAt"] as String, equalTo(formattedDate)) - assertThat((captor.firstValue as Map<*, *>)["scopes"], equalTo(listOf("scope1", "scope2"))) - assertThat(((captor.firstValue as Map<*, *>)["userProfile"] as Map<*, *>)["name"], equalTo("John Doe")) + assertThat(resultMap["accessToken"], equalTo(credentials.accessToken)) + assertThat(resultMap["idToken"], equalTo(credentials.idToken)) + assertThat(resultMap["refreshToken"], equalTo(credentials.refreshToken)) + assertThat(resultMap["expiresAt"] as String, equalTo(formattedDate)) + assertThat(resultMap["scopes"], equalTo(listOf("scope1", "scope2"))) + assertThat((resultMap["userProfile"] as Map<*, *>)["name"], equalTo("John Doe")) } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt index 5bb10185f..38a6d8312 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt @@ -138,11 +138,11 @@ class LoginWithOtpApiRequestHandlerTest { val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe")) val credentials = Credentials(idToken, "test", "", null, Date(), "scope1 scope2") - doReturn(mockLoginBuilder).`when`(mockApi).loginWithOTP(any(), any()) - doAnswer { - val ob = it.getArgument>(0) - ob.onSuccess(credentials) - }.`when`(mockLoginBuilder).start(any()) + whenever(mockApi.loginWithOTP(any(), any())).thenReturn(mockLoginBuilder) + whenever(mockLoginBuilder.start(any())).thenAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(credentials) + } handler.handle( mockApi, @@ -150,19 +150,18 @@ class LoginWithOtpApiRequestHandlerTest { mockResult ) - val captor = argumentCaptor<() -> Map>() + val captor = argumentCaptor>() verify(mockResult).success(captor.capture()) - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(credentials.expiresAt) + val formattedDate = credentials.expiresAt.toInstant().toString() + + val resultMap = captor.firstValue - assertThat((captor.firstValue as Map<*, *>)["accessToken"], equalTo(credentials.accessToken)) - assertThat((captor.firstValue as Map<*, *>)["idToken"], equalTo(credentials.idToken)) - assertThat((captor.firstValue as Map<*, *>)["refreshToken"], equalTo(credentials.refreshToken)) - assertThat((captor.firstValue as Map<*, *>)["expiresAt"] as String, equalTo(formattedDate)) - assertThat((captor.firstValue as Map<*, *>)["scopes"], equalTo(listOf("scope1", "scope2"))) - assertThat(((captor.firstValue as Map<*, *>)["userProfile"] as Map<*, *>)["name"], equalTo("John Doe")) + assertThat(resultMap["accessToken"], equalTo(credentials.accessToken)) + assertThat(resultMap["idToken"], equalTo(credentials.idToken)) + assertThat(resultMap["refreshToken"], equalTo(credentials.refreshToken)) + assertThat(resultMap["expiresAt"] as String, equalTo(formattedDate)) + assertThat(resultMap["scopes"], equalTo(listOf("scope1", "scope2"))) + assertThat((resultMap["userProfile"] as Map<*, *>)["name"], equalTo("John Doe")) } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/RenewApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/RenewApiRequestHandlerTest.kt index 96f93c83e..eff304d35 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/RenewApiRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/RenewApiRequestHandlerTest.kt @@ -57,7 +57,7 @@ class RenewApiRequestHandlerTest { val mockResult = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) handler.handle( mockApi, @@ -77,13 +77,13 @@ class RenewApiRequestHandlerTest { ) val handler = RenewApiRequestHandler() val mockBuilder = mock>() - val mockApi = mock() val mockAccount = mock() val mockResult = mock() + val mockApi = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameter(any(), any()) + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) + whenever(mockBuilder.addParameter("scope", "scope1 scope2")).thenReturn(mockBuilder) handler.handle( mockApi, @@ -92,6 +92,7 @@ class RenewApiRequestHandlerTest { ) verify(mockBuilder).addParameter("scope", "scope1 scope2") + verify(mockBuilder).start(any()) } @Test @@ -101,13 +102,12 @@ class RenewApiRequestHandlerTest { ) val handler = RenewApiRequestHandler() val mockBuilder = mock>() - val mockApi = mock() val mockAccount = mock() val mockResult = mock() + val mockApi = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameter(any(), any()) + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) handler.handle( mockApi, @@ -116,6 +116,7 @@ class RenewApiRequestHandlerTest { ) verify(mockBuilder, times(0)).addParameter(any(), any()) + verify(mockBuilder).start(any()) } @Test @@ -126,13 +127,13 @@ class RenewApiRequestHandlerTest { ) val handler = RenewApiRequestHandler() val mockBuilder = mock>() - val mockApi = mock() val mockAccount = mock() val mockResult = mock() + val mockApi = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) + whenever(mockBuilder.addParameters(any())).thenReturn(mockBuilder) handler.handle( mockApi, @@ -146,6 +147,7 @@ class RenewApiRequestHandlerTest { "test2" to "test-value" ) ) + verify(mockBuilder).start(any()) } @Test @@ -155,13 +157,12 @@ class RenewApiRequestHandlerTest { ) val handler = RenewApiRequestHandler() val mockBuilder = mock>() - val mockApi = mock() val mockAccount = mock() val mockResult = mock() + val mockApi = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) handler.handle( mockApi, @@ -170,6 +171,7 @@ class RenewApiRequestHandlerTest { ) verify(mockBuilder, times(0)).addParameters(any()) + verify(mockBuilder).start(any()) } @Test @@ -178,20 +180,20 @@ class RenewApiRequestHandlerTest { "refreshToken" to "test-token", ) val handler = RenewApiRequestHandler() - val mockBuilder = mock>() - val mockApi = mock() val mockAccount = mock() val mockResult = mock() val request = MethodCallRequest(account = mockAccount, options) val exception = AuthenticationException(code = "test-code", description = "test-description") - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) - doAnswer { - val ob = it.getArgument>(0) - ob.onFailure(exception) - }.`when`(mockBuilder).start(any()) + val mockBuilder = mock>() + val mockApi = mock() + + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) + whenever(mockBuilder.start(any())).thenAnswer { + val callback = it.getArgument>(0) + callback.onFailure(exception) + } handler.handle( mockApi, @@ -208,20 +210,20 @@ class RenewApiRequestHandlerTest { "refreshToken" to "test-token", ) val handler = RenewApiRequestHandler() - val mockBuilder = mock>() - val mockApi = mock() val mockAccount = mock() val mockResult = mock() val request = MethodCallRequest(account = mockAccount, options) val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe")) val credentials = Credentials(idToken, "test", "", null, Date(), "scope1 scope2") - doReturn(mockBuilder).`when`(mockApi).renewAuth(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) - doAnswer { - val ob = it.getArgument>(0) - ob.onSuccess(credentials) - }.`when`(mockBuilder).start(any()) + val mockBuilder = mock>() + val mockApi = mock() + + whenever(mockApi.renewAuth("test-token")).thenReturn(mockBuilder) + whenever(mockBuilder.start(any())).thenAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(credentials) + } handler.handle( mockApi, @@ -229,33 +231,32 @@ class RenewApiRequestHandlerTest { mockResult ) - val captor = argumentCaptor<() -> Map>() + val captor = argumentCaptor>() verify(mockResult).success(captor.capture()) - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(credentials.expiresAt) + val formattedDate = credentials.expiresAt.toInstant().toString() + + val resultMap = captor.firstValue assertThat( - (captor.firstValue as Map<*, *>)["accessToken"], + resultMap["accessToken"], equalTo(credentials.accessToken) ) - assertThat((captor.firstValue as Map<*, *>)["idToken"], equalTo(credentials.idToken)) + assertThat(resultMap["idToken"], equalTo(credentials.idToken)) assertThat( - (captor.firstValue as Map<*, *>)["refreshToken"], + resultMap["refreshToken"], equalTo(credentials.refreshToken) ) assertThat( - (captor.firstValue as Map<*, *>)["expiresAt"] as String, + resultMap["expiresAt"] as String, equalTo(formattedDate) ) assertThat( - (captor.firstValue as Map<*, *>)["scopes"], + resultMap["scopes"], equalTo(listOf("scope1", "scope2")) ) assertThat( - ((captor.firstValue as Map<*, *>)["userProfile"] as Map<*, *>)["name"], + (resultMap["userProfile"] as Map<*, *>)["name"], equalTo("John Doe") ) } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandlerTest.kt index 7a8ba1033..29cbbcbcb 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/UserInfoApiRequestHandlerTest.kt @@ -55,7 +55,7 @@ class UserInfoApiRequestHandlerTest { val mockResult = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).userInfo(any()) + whenever(mockApi.userInfo("test-token", "Bearer")).thenReturn(mockBuilder) handler.handle( mockApi, @@ -63,7 +63,7 @@ class UserInfoApiRequestHandlerTest { mockResult ) - verify(mockApi).userInfo("test-token") + verify(mockApi).userInfo("test-token", "Bearer") verify(mockBuilder).start(any()) } @@ -80,8 +80,8 @@ class UserInfoApiRequestHandlerTest { val mockResult = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).userInfo(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) + whenever(mockApi.userInfo("test-token", "Bearer")).thenReturn(mockBuilder) + whenever(mockBuilder.addParameters(any())).thenReturn(mockBuilder) handler.handle( mockApi, @@ -95,6 +95,7 @@ class UserInfoApiRequestHandlerTest { "test2" to "test-value" ) ) + verify(mockBuilder).start(any()) } @Test @@ -109,8 +110,7 @@ class UserInfoApiRequestHandlerTest { val mockResult = mock() val request = MethodCallRequest(account = mockAccount, options) - doReturn(mockBuilder).`when`(mockApi).userInfo(any()) - doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) + whenever(mockApi.userInfo("test-token", "Bearer")).thenReturn(mockBuilder) handler.handle( mockApi, @@ -119,6 +119,56 @@ class UserInfoApiRequestHandlerTest { ) verify(mockBuilder, times(0)).addParameters(any()) + verify(mockBuilder).start(any()) + } + + @Test + fun `should default tokenType to Bearer when not provided`() { + val options = hashMapOf( + "accessToken" to "test-token", + ) + val handler = UserInfoApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockApi.userInfo("test-token", "Bearer")).thenReturn(mockBuilder) + + handler.handle( + mockApi, + request, + mockResult + ) + + verify(mockApi).userInfo("test-token", "Bearer") + verify(mockBuilder).start(any()) + } + + @Test + fun `should use custom tokenType when provided`() { + val options = hashMapOf( + "accessToken" to "test-token", + "tokenType" to "DPoP" + ) + val handler = UserInfoApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockApi.userInfo("test-token", "DPoP")).thenReturn(mockBuilder) + + handler.handle( + mockApi, + request, + mockResult + ) + + verify(mockApi).userInfo("test-token", "DPoP") + verify(mockBuilder).start(any()) } @Test @@ -135,11 +185,11 @@ class UserInfoApiRequestHandlerTest { val exception = AuthenticationException(code = "test-code", description = "test-description") - doReturn(mockBuilder).`when`(mockApi).userInfo(any()) - doAnswer { - val ob = it.getArgument>(0) - ob.onFailure(exception) - }.`when`(mockBuilder).start(any()) + whenever(mockApi.userInfo("test-token", "Bearer")).thenReturn(mockBuilder) + whenever(mockBuilder.start(any())).thenAnswer { + val callback = it.getArgument>(0) + callback.onFailure(exception) + } handler.handle( mockApi, @@ -177,11 +227,11 @@ class UserInfoApiRequestHandlerTest { null ) - doReturn(mockBuilder).`when`(mockApi).userInfo(any()) - doAnswer { - val ob = it.getArgument>(0) - ob.onSuccess(user) - }.`when`(mockBuilder).start(any()) + whenever(mockApi.userInfo("test-token", "Bearer")).thenReturn(mockBuilder) + whenever(mockBuilder.start(any())).thenAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(user) + } handler.handle( mockApi, @@ -189,11 +239,13 @@ class UserInfoApiRequestHandlerTest { mockResult ) - val captor = argumentCaptor<() -> Map>() + val captor = argumentCaptor>() verify(mockResult).success(captor.capture()) + + val resultMap = captor.firstValue - assertThat((captor.firstValue as Map<*, *>)["sub"], equalTo(user.sub)) - assertThat((captor.firstValue as Map<*, *>)["name"], equalTo(user.name)) + assertThat(resultMap["sub"], equalTo(user.sub)) + assertThat(resultMap["name"], equalTo(user.name)) } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandlerTest.kt index 8b4d3cb97..6c24d90ad 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandlerTest.kt @@ -301,10 +301,10 @@ class GetCredentialsRequestHandlerTest { val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe")) val credentials = Credentials(idToken, "test", "", null, Date(), "scope1 scope2") - doAnswer { - val ob = it.getArgument>(3) - ob.onSuccess(credentials) - }.`when`(mockCredentialsManager).getCredentials(isNull(), anyInt(), anyMap(), any()) + whenever(mockCredentialsManager.getCredentials(isNull(), anyInt(), anyMap(), any())).thenAnswer { + val callback = it.getArgument>(3) + callback.onSuccess(credentials) + } handler.handle( mockCredentialsManager, @@ -313,36 +313,35 @@ class GetCredentialsRequestHandlerTest { mockResult ) - val captor = argumentCaptor<() -> Map>() + val captor = argumentCaptor>() verify(mockResult).success(captor.capture()) - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(credentials.expiresAt) + val formattedDate = credentials.expiresAt.toInstant().toString() + + val resultMap = captor.firstValue MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["accessToken"], + resultMap["accessToken"], CoreMatchers.equalTo(credentials.accessToken) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["idToken"], + resultMap["idToken"], CoreMatchers.equalTo(credentials.idToken) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["refreshToken"], + resultMap["refreshToken"], CoreMatchers.equalTo(credentials.refreshToken) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["expiresAt"] as String, + resultMap["expiresAt"] as String, CoreMatchers.equalTo(formattedDate) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["scopes"], + resultMap["scopes"], CoreMatchers.equalTo(listOf("scope1", "scope2")) ) MatcherAssert.assertThat( - ((captor.firstValue as Map<*, *>)["userProfile"] as Map<*, *>)["name"], + (resultMap["userProfile"] as Map<*, *>)["name"], CoreMatchers.equalTo("John Doe") ) } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandlerTest.kt index 3a853484b..5772a1465 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandlerTest.kt @@ -22,6 +22,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.text.SimpleDateFormat import java.util.Date @@ -151,11 +152,10 @@ class RenewCredentialsRequestHandlerTest { val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe")) val credentials = Credentials(idToken, "accessToken", "Bearer", null, Date(), "scope1") - doAnswer { - val ob = it.getArgument>(4) - ob.onSuccess(credentials) - }.`when`(mockCredentialsManager) - .getCredentials(isNull(), anyInt(), anyMap(), eq(true), any()) + whenever(mockCredentialsManager.getCredentials(isNull(), anyInt(), anyMap(), eq(true), any())).thenAnswer { + val callback = it.getArgument>(4) + callback.onSuccess(credentials) + } handler.handle( mockCredentialsManager, @@ -164,37 +164,35 @@ class RenewCredentialsRequestHandlerTest { mockResult ) - val captor = argumentCaptor<() -> Map>() + val captor = argumentCaptor>() verify(mockResult).success(captor.capture()) - val sdf = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - val formattedDate = sdf.format(credentials.expiresAt) + val formattedDate = credentials.expiresAt.toInstant().toString() + val resultMap = captor.firstValue MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["accessToken"], + resultMap["accessToken"], CoreMatchers.equalTo(credentials.accessToken) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["idToken"], + resultMap["idToken"], CoreMatchers.equalTo(credentials.idToken) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["refreshToken"], + resultMap["refreshToken"], CoreMatchers.equalTo(credentials.refreshToken) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["expiresAt"] as String, + resultMap["expiresAt"] as String, CoreMatchers.equalTo(formattedDate) ) MatcherAssert.assertThat( - (captor.firstValue as Map<*, *>)["scopes"], + resultMap["scopes"], CoreMatchers.equalTo(listOf("scope1")) ) MatcherAssert.assertThat( - ((captor.firstValue as Map<*, *>)["userProfile"] as Map<*, *>)["name"], + (resultMap["userProfile"] as Map<*, *>)["name"], CoreMatchers.equalTo("John Doe") ) } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/SaveCredentialsRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/SaveCredentialsRequestHandlerTest.kt index a3f6aeb94..03c0c09cc 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/SaveCredentialsRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/SaveCredentialsRequestHandlerTest.kt @@ -172,6 +172,7 @@ class SaveCredentialsRequestHandlerTest { "scopes" to arrayListOf("a", "b") ) val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + format.timeZone = TimeZone.getTimeZone("UTC") val date = format.parse(credentialsMap["expiresAt"] as String) as Date var scope: String? = null val scopes = (credentialsMap["scopes"] ?: arrayListOf()) as ArrayList<*> diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift index d7be0d1b3..e2380a1f4 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift @@ -8,7 +8,7 @@ import FlutterMacOS // MARK: - Providers -typealias AuthAPIClientProvider = (_ account: Account, _ userAgent: UserAgent) -> Authentication +typealias AuthAPIClientProvider = (_ account: Account, _ userAgent: UserAgent, _ arguments: [String: Any]) -> Authentication typealias AuthAPIMethodHandlerProvider = (_ method: AuthAPIHandler.Method, _ client: Authentication) -> MethodHandler // MARK: - Auth Auth Handler @@ -44,9 +44,15 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(handler, channel: channel) } - var clientProvider: AuthAPIClientProvider = { account, userAgent in + var clientProvider: AuthAPIClientProvider = { account, userAgent, arguments in var client = Auth0.authentication(clientId: account.clientId, domain: account.domain) client.using(inLibrary: userAgent.name, version: userAgent.version) + + let useDPoP = arguments["useDPoP"] as? Bool ?? false + if useDPoP { + client = client.useDPoP() + } + return client } @@ -82,7 +88,7 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { return result(FlutterMethodNotImplemented) } - let client = clientProvider(account, userAgent) + let client = clientProvider(account, userAgent, arguments) let methodHandler = methodHandlerProvider(method, client) methodHandler.handle(with: arguments, callback: result) diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIUserInfoMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIUserInfoMethodHandler.swift index a787c3de9..2790d109a 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIUserInfoMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIUserInfoMethodHandler.swift @@ -16,6 +16,7 @@ struct AuthAPIUserInfoMethodHandler: MethodHandler { enum Argument: String { case accessToken case parameters + case tokenType } let client: Authentication @@ -27,9 +28,11 @@ struct AuthAPIUserInfoMethodHandler: MethodHandler { guard let parameters = arguments[Argument.parameters] as? [String: Any] else { return callback(FlutterError(from: .requiredArgumentMissing(Argument.parameters.rawValue))) } + + let tokenType = arguments[Argument.tokenType] as? String ?? "Bearer" client - .userInfo(withAccessToken: accessToken) + .userInfo(withAccessToken: accessToken, tokenType: tokenType) .parameters(parameters) .start { switch $0 { diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift index e699b6945..642fecef1 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift @@ -24,9 +24,19 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case renew = "credentialsManager#renewCredentials" case clear = "credentialsManager#clearCredentials" } + + private struct ManagerCacheKey: Equatable { + let accountDomain: String + let accountClientId: String + let storeKey: String + let accessGroup: String? + let useDPoP: Bool + let hasLocalAuth: Bool + } private static let channelName = "auth0.com/auth0_flutter/credentials_manager" private static var credentialsManager: CredentialsManager? + private static var cachedKey: ManagerCacheKey? public static func register(with registrar: FlutterPluginRegistrar) { let handler = CredentialsManagerHandler() @@ -61,26 +71,55 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { } } - var apiClientProvider: AuthAPIClientProvider = { account, userAgent in + var apiClientProvider: AuthAPIClientProvider = { account, userAgent, arguments in + let useDPoP = arguments["useDPoP"] as? Bool ?? false var client = Auth0.authentication(clientId: account.clientId, domain: account.domain) client.using(inLibrary: userAgent.name, version: userAgent.version) - return client + return useDPoP ? client.useDPoP() : client } lazy var credentialsManagerProvider: CredentialsManagerProvider = { apiClient, arguments in + let configuration = arguments["credentialsManagerConfiguration"] as? [String: Any] + let iosConfiguration = configuration?["ios"] as? [String: String] + let storeKey = iosConfiguration?["storeKey"] ?? "credentials" + let accessGroup = iosConfiguration?["accessGroup"] + let useDPoP = arguments["useDPoP"] as? Bool ?? false + let hasLocalAuth = arguments[LocalAuthentication.key] != nil + + guard let accountDictionary = arguments[Account.key] as? [String: String], + let account = Account(from: accountDictionary) else { + return self.createCredentialManager(apiClient, arguments) + } + + let currentKey = ManagerCacheKey( + accountDomain: account.domain, + accountClientId: account.clientId, + storeKey: storeKey, + accessGroup: accessGroup, + useDPoP: useDPoP, + hasLocalAuth: hasLocalAuth + ) + + var instance: CredentialsManager + if let cachedKey = CredentialsManagerHandler.cachedKey, + cachedKey == currentKey, + let cachedManager = CredentialsManagerHandler.credentialsManager { + instance = cachedManager + } else { + instance = self.createCredentialManager(apiClient, arguments) + + CredentialsManagerHandler.credentialsManager = instance + CredentialsManagerHandler.cachedKey = currentKey + } - var instance = CredentialsManagerHandler.credentialsManager ?? - self.createCredentialManager(apiClient,arguments) - if let localAuthenticationDictionary = arguments[LocalAuthentication.key] as? [String: String?] { let localAuthentication = LocalAuthentication(from: localAuthenticationDictionary) instance.enableBiometrics(withTitle: localAuthentication.title, cancelTitle: localAuthentication.cancelTitle, fallbackTitle: localAuthentication.fallbackTitle) } - - CredentialsManagerHandler.credentialsManager = instance + return instance } @@ -110,7 +149,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { return result(FlutterMethodNotImplemented) } - let apiClient = apiClientProvider(account, userAgent) + let apiClient = apiClientProvider(account, userAgent, arguments) let credentialsManager = credentialsManagerProvider(apiClient, arguments) let methodHandler = methodHandlerProvider(method, credentialsManager) methodHandler.handle(with: arguments, callback: result) diff --git a/auth0_flutter/darwin/Classes/DPoP/DPoPClearKeyMethodHandler.swift b/auth0_flutter/darwin/Classes/DPoP/DPoPClearKeyMethodHandler.swift new file mode 100644 index 000000000..88091de20 --- /dev/null +++ b/auth0_flutter/darwin/Classes/DPoP/DPoPClearKeyMethodHandler.swift @@ -0,0 +1,20 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct DPoPClearKeyMethodHandler: MethodHandler { + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + do { + try DPoP.clearKeypair() + callback(nil) + } catch { + callback(FlutterError(code: "CLEAR_DPOP_KEY_ERROR", + message: error.localizedDescription, + details: nil)) + } + } +} diff --git a/auth0_flutter/darwin/Classes/DPoP/DPoPGetHeadersMethodHandler.swift b/auth0_flutter/darwin/Classes/DPoP/DPoPGetHeadersMethodHandler.swift new file mode 100644 index 000000000..6a796dfc6 --- /dev/null +++ b/auth0_flutter/darwin/Classes/DPoP/DPoPGetHeadersMethodHandler.swift @@ -0,0 +1,55 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct DPoPGetHeadersMethodHandler: MethodHandler { + enum Argument: String { + case url + case method + case accessToken + case tokenType + } + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let urlString = arguments[Argument.url] as? String, + let url = URL(string: urlString) else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.url.rawValue))) + } + guard let method = arguments[Argument.method] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.method.rawValue))) + } + guard let accessToken = arguments[Argument.accessToken] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.accessToken.rawValue))) + } + guard let tokenType = arguments[Argument.tokenType] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.tokenType.rawValue))) + } + + let nonce = arguments["nonce"] as? String + + var request = URLRequest(url: url) + request.httpMethod = method + + do { + try DPoP.addHeaders(to: &request, + accessToken: accessToken, + tokenType: tokenType, + nonce: nonce) + + let result: [String: String] = [ + "authorization": request.value(forHTTPHeaderField: "Authorization") ?? "\(tokenType) \(accessToken)", + "dpop": request.value(forHTTPHeaderField: "DPoP") ?? "" + ] + + callback(result) + } catch { + callback(FlutterError(code: "GET_DPOP_HEADERS_ERROR", + message: error.localizedDescription, + details: nil)) + } + } +} diff --git a/auth0_flutter/darwin/Classes/DPoP/DPoPHandler.swift b/auth0_flutter/darwin/Classes/DPoP/DPoPHandler.swift new file mode 100644 index 000000000..b9466cd16 --- /dev/null +++ b/auth0_flutter/darwin/Classes/DPoP/DPoPHandler.swift @@ -0,0 +1,50 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + + +public class DPoPHandler: NSObject, FlutterPlugin { + enum Method: String, CaseIterable { + case getDPoPHeaders = "dpop#getDPoPHeaders" + case clearDPoPKey = "dpop#clearDPoPKey" + } + + private static let channelName = "auth0.com/auth0_flutter/dpop" + + public static func register(with registrar: FlutterPluginRegistrar) { + let handler = DPoPHandler() + + #if os(iOS) + let channel = FlutterMethodChannel(name: DPoPHandler.channelName, + binaryMessenger: registrar.messenger()) + #else + let channel = FlutterMethodChannel(name: DPoPHandler.channelName, + binaryMessenger: registrar.messenger) + #endif + + registrar.addMethodCallDelegate(handler, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any] else { + return result(FlutterError(from: .argumentsMissing)) + } + guard let method = Method(rawValue: call.method) else { + return result(FlutterMethodNotImplemented) + } + + let methodHandler: MethodHandler + switch method { + case .getDPoPHeaders: + methodHandler = DPoPGetHeadersMethodHandler() + case .clearDPoPKey: + methodHandler = DPoPClearKeyMethodHandler() + } + + methodHandler.handle(with: arguments, callback: result) + } +} diff --git a/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift b/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift index 402b184bd..490436710 100644 --- a/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift +++ b/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift @@ -7,6 +7,7 @@ import FlutterMacOS public class SwiftAuth0FlutterPlugin: NSObject, FlutterPlugin { static var handlers: [FlutterPlugin.Type] = [WebAuthHandler.self, AuthAPIHandler.self, + DPoPHandler.self, CredentialsManagerHandler.self] public static func register(with registrar: FlutterPluginRegistrar) { diff --git a/auth0_flutter/darwin/Classes/WebAuth/WebAuthLoginMethodHandler.swift b/auth0_flutter/darwin/Classes/WebAuth/WebAuthLoginMethodHandler.swift index 904e45526..b1e462427 100644 --- a/auth0_flutter/darwin/Classes/WebAuth/WebAuthLoginMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/WebAuth/WebAuthLoginMethodHandler.swift @@ -22,6 +22,7 @@ struct WebAuthLoginMethodHandler: MethodHandler { case organizationId case invitationUrl case leeway + case useDPoP case issuer case maxAge #if os(iOS) @@ -106,6 +107,10 @@ struct WebAuthLoginMethodHandler: MethodHandler { } #endif + if arguments[Argument.useDPoP.rawValue] as? Bool == true { + webAuth = webAuth.useDPoP() + } + webAuth.start { switch $0 { case let .success(credentials): callback(result(from: credentials)) diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec index 2c276c80e..16aadb281 100644 --- a/auth0_flutter/darwin/auth0_flutter.podspec +++ b/auth0_flutter/darwin/auth0_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'auth0_flutter' - s.version = '1.14.0' + s.version = '2.0.0-beta.1' s.summary = 'Auth0 SDK for Flutter' s.description = 'Auth0 SDK for Flutter Android and iOS apps.' s.homepage = 'https://auth0.com' @@ -19,9 +19,9 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.10.0' - s.dependency 'JWTDecode', '3.2.0' - s.dependency 'SimpleKeychain', '1.2.0' + s.dependency 'Auth0', '2.14.0' + s.dependency 'JWTDecode', '3.3.0' + s.dependency 'SimpleKeychain', '1.3.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/auth0_flutter/example/android/app/build.gradle b/auth0_flutter/example/android/app/build.gradle index 5efde64f1..bdf518cd8 100644 --- a/auth0_flutter/example/android/app/build.gradle +++ b/auth0_flutter/example/android/app/build.gradle @@ -26,6 +26,7 @@ if (flutterVersionName == null) { android { compileSdk 34 + if (project.android.hasProperty("namespace")) { namespace exampleAppApplicationId } @@ -47,7 +48,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId exampleAppApplicationId minSdkVersion 24 - targetSdk 34 + targetSdk 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt b/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt index 91f2b18b9..3726d88b8 100644 --- a/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt +++ b/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt @@ -1,6 +1,6 @@ package com.auth0.auth0_flutter_example -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity: FlutterActivity() { +class MainActivity: FlutterFragmentActivity() { } diff --git a/auth0_flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/auth0_flutter/example/android/gradle/wrapper/gradle-wrapper.properties index 052e0719f..d951fac2b 100644 --- a/auth0_flutter/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/auth0_flutter/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jan 03 12:46:32 IST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/auth0_flutter/example/android/settings.gradle b/auth0_flutter/example/android/settings.gradle index bf2328f82..df37de8ab 100644 --- a/auth0_flutter/example/android/settings.gradle +++ b/auth0_flutter/example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.4.2" apply false + id "com.android.application" version "8.3.0" apply false id "org.jetbrains.kotlin.android" version "1.9.10" apply false } -include ':app' +include ':app' \ No newline at end of file diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift index 5f19034ac..d6119e0e9 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift @@ -64,7 +64,7 @@ extension AuthAPIHandlerTests { let userAgentDictionary = [UserAgentProperty.name.rawValue: "baz", UserAgentProperty.version.rawValue: "qux"] let argumentsDictionary = [Account.key: accountDictionary, UserAgent.key: userAgentDictionary] let expectation = self.expectation(description: "Called client provider") - sut.clientProvider = { account, userAgent in + sut.clientProvider = { (account: Account, userAgent: UserAgent, arguments: [String: Any]) -> Authentication in XCTAssertEqual(account.clientId, accountDictionary[AccountProperty.clientId]) XCTAssertEqual(account.domain, accountDictionary[AccountProperty.domain]) XCTAssertEqual(userAgent.name, userAgentDictionary[UserAgentProperty.name]) diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIRenewMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIRenewMethodHandlerTests.swift index e2eba1ebd..6bc0de200 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIRenewMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIRenewMethodHandlerTests.swift @@ -27,7 +27,7 @@ extension AuthAPIRenewMethodHandlerTests { currentExpectation.fulfill() } } - wait(for: expectations) + wait(for: expectations, timeout: 5.0) } } @@ -42,7 +42,7 @@ extension AuthAPIRenewMethodHandlerTests { assert(result: result, isError: .idTokenDecodingFailed) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } } @@ -94,7 +94,7 @@ extension AuthAPIRenewMethodHandlerTests { assert(result: result, has: CredentialsProperty.allCases) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } func testProducesAuthenticationError() { @@ -105,7 +105,7 @@ extension AuthAPIRenewMethodHandlerTests { assert(result: result, isError: error) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } } diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift index c1c60b4a2..2e3233e1c 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift @@ -4,14 +4,24 @@ fileprivate let mockCredentials = Credentials() fileprivate let mockChallenge = Challenge(challengeType: "", oobCode: nil, bindingMethod: nil) fileprivate let mockDatabaseUser: DatabaseUser = (email: "", username: nil, verified: true) fileprivate let mockUserInfo = UserInfo(json: ["sub": ""])! +fileprivate let mockSSOCredentials = SSOCredentials( + sessionTransferToken: "token", + issuedTokenType: "type", + expiresIn: Date(), + idToken: testIdToken, + refreshToken: nil +) class SpyAuthentication: Authentication { let clientId = "" let url = mockURL var telemetry = Telemetry() var logger: Logger? + var sender: String = "auth0-flutter" + var dpop: DPoP? var credentialsResult: AuthenticationResult = .success(mockCredentials) + var ssoCredentialsResult: AuthenticationResult = .success(mockSSOCredentials) var challengeResult: AuthenticationResult = .success(mockChallenge) var databaseUserResult: AuthenticationResult = .success(mockDatabaseUser) var userInfoResult: AuthenticationResult = .success(mockUserInfo) @@ -85,8 +95,9 @@ class SpyAuthentication: Authentication { return request(voidResult) } - func userInfo(withAccessToken accessToken: String) -> Request { + func userInfo(withAccessToken accessToken: String, tokenType: String) -> Request { arguments["accessToken"] = accessToken + arguments["tokenType"] = tokenType calledUserInfo = true return request(userInfoResult) } @@ -96,14 +107,20 @@ class SpyAuthentication: Authentication { redirectURI: String) -> Request { return request(credentialsResult) } - - func renew(withRefreshToken refreshToken: String, scope: String?) -> Request { + + func renew(withRefreshToken refreshToken: String, audience: String?, scope: String?) -> Request { arguments["refreshToken"] = refreshToken arguments["scope"] = scope + arguments["audience"] = audience calledRenew = true return request(credentialsResult) } + func ssoExchange(withRefreshToken refreshToken: String) -> Request { + arguments["refreshToken"] = refreshToken + return request(ssoCredentialsResult) + } + func revoke(refreshToken: String) -> Request { return request(voidResult) } diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIUserInfoMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIUserInfoMethodHandlerTests.swift index 0fd27e8fc..64e52eccd 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIUserInfoMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIUserInfoMethodHandlerTests.swift @@ -27,7 +27,7 @@ extension AuthAPIUserInfoMethodHandlerTests { currentExpectation.fulfill() } } - wait(for: expectations) + wait(for: expectations, timeout: 5.0) } } @@ -84,7 +84,7 @@ extension AuthAPIUserInfoMethodHandlerTests { assert(result: result, has: UserInfoProperty.allCases) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } func testProducesAuthenticationError() { @@ -95,7 +95,7 @@ extension AuthAPIUserInfoMethodHandlerTests { assert(result: result, isError: error) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } } diff --git a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerGetMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerGetMethodHandlerTests.swift index 784307fbf..760907e8d 100644 --- a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerGetMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerGetMethodHandlerTests.swift @@ -30,7 +30,7 @@ extension CredentialsManagerGetMethodHandlerTests { currentExpectation.fulfill() } } - wait(for: expectations) + wait(for: expectations, timeout: 5.0) } } @@ -55,7 +55,7 @@ extension CredentialsManagerGetMethodHandlerTests { XCTAssertEqual(self.spyAuthentication.arguments["scope"] as? String, value.asSpaceSeparatedString) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } func testAddsNilScopeWhenEmpty() { @@ -72,7 +72,7 @@ extension CredentialsManagerGetMethodHandlerTests { XCTAssertNil(self.spyAuthentication.arguments["scope"] as? String) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } } @@ -85,7 +85,7 @@ extension CredentialsManagerGetMethodHandlerTests { XCTAssertTrue(self.spyStorage.calledGetEntry) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } func testProducesCredentials() { @@ -102,7 +102,7 @@ extension CredentialsManagerGetMethodHandlerTests { assert(result: result, has: CredentialsProperty.allCases) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } func testProducesCredentialsManagerError() { @@ -113,7 +113,7 @@ extension CredentialsManagerGetMethodHandlerTests { assert(result: result, isError: error) expectation.fulfill() } - wait(for: [expectation]) + wait(for: [expectation], timeout: 5.0) } } diff --git a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift index 6dbfa4cae..660f944db 100644 --- a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift @@ -194,7 +194,7 @@ extension CredentialsManagerHandlerTests { let userAgentDictionary = [UserAgentProperty.name.rawValue: "baz", UserAgentProperty.version.rawValue: "qux"] let argumentsDictionary = [Account.key: accountDictionary, UserAgent.key: userAgentDictionary] let expectation = self.expectation(description: "Called API client provider") - sut.apiClientProvider = { account, userAgent in + sut.apiClientProvider = { (account: Account, userAgent: UserAgent, arguments: [String: Any]) -> Authentication in XCTAssertEqual(account.clientId, accountDictionary[AccountProperty.clientId]) XCTAssertEqual(account.domain, accountDictionary[AccountProperty.domain]) XCTAssertEqual(userAgent.name, userAgentDictionary[UserAgentProperty.name]) @@ -211,7 +211,7 @@ extension CredentialsManagerHandlerTests { func testCallsCredentialsManagerProvider() { let methodName = CredentialsManagerHandler.Method.save.rawValue let expectation = self.expectation(description: "Called credentials manager provider") - sut.apiClientProvider = { _, _ in + sut.apiClientProvider = { _, _, _ in return SpyAuthentication() } sut.credentialsManagerProvider = { _, _ in @@ -423,3 +423,84 @@ extension CredentialsManagerHandlerTests { wait(for: [expectation]) } } + +// MARK: - DPoP Support + +extension CredentialsManagerHandlerTests { + func testUsesRegularAuthClientWhenDPoPIsFalse() { + let expectation = expectation(description: "Uses regular auth client when useDPoP is false") + let method = CredentialsManagerHandler.Method.save.rawValue + + var usedDPoP = false + var args = arguments() + args["useDPoP"] = false + + sut.apiClientProvider = { (account: Account, userAgent: UserAgent, arguments: [String: Any]) -> Authentication in + let client = Auth0.authentication(clientId: account.clientId, domain: account.domain) + return client + } + + sut.credentialsManagerProvider = { apiClient, arguments in + usedDPoP = arguments["useDPoP"] as? Bool ?? false + return self.sut.createCredentialManager(apiClient, arguments) + } + + sut.handle(FlutterMethodCall(methodName: method, arguments: args)) { _ in + XCTAssertFalse(usedDPoP) + expectation.fulfill() + } + + wait(for: [expectation]) + } + + func testUsesDPoPAuthClientWhenDPoPIsTrue() { + let expectation = expectation(description: "Uses DPoP auth client when useDPoP is true") + let method = CredentialsManagerHandler.Method.save.rawValue + + var usedDPoP = false + var args = arguments() + args["useDPoP"] = true + + sut.apiClientProvider = { (account: Account, userAgent: UserAgent, arguments: [String: Any]) -> Authentication in + let client = Auth0.authentication(clientId: account.clientId, domain: account.domain) + return client + } + + sut.credentialsManagerProvider = { apiClient, arguments in + usedDPoP = arguments["useDPoP"] as? Bool ?? false + return self.sut.createCredentialManager(apiClient, arguments) + } + + sut.handle(FlutterMethodCall(methodName: method, arguments: args)) { _ in + XCTAssertTrue(usedDPoP) + expectation.fulfill() + } + + wait(for: [expectation]) + } + + func testDefaultsToDPoPFalseWhenNotProvided() { + let expectation = expectation(description: "Defaults to DPoP false when not provided") + let method = CredentialsManagerHandler.Method.save.rawValue + + var usedDPoP: Bool? = nil + var args = arguments() + + sut.apiClientProvider = { (account: Account, userAgent: UserAgent, arguments: [String: Any]) -> Authentication in + let client = Auth0.authentication(clientId: account.clientId, domain: account.domain) + return client + } + + sut.credentialsManagerProvider = { apiClient, arguments in + usedDPoP = arguments["useDPoP"] as? Bool + return self.sut.createCredentialManager(apiClient, arguments) + } + + sut.handle(FlutterMethodCall(methodName: method, arguments: args)) { _ in + XCTAssertEqual(usedDPoP ?? false, false) + expectation.fulfill() + } + + wait(for: [expectation]) + } +} \ No newline at end of file diff --git a/auth0_flutter/example/ios/Tests/Mocks.swift b/auth0_flutter/example/ios/Tests/Mocks.swift index 4f801e6a7..eac01e6d2 100644 --- a/auth0_flutter/example/ios/Tests/Mocks.swift +++ b/auth0_flutter/example/ios/Tests/Mocks.swift @@ -113,9 +113,7 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { } func addApplicationDelegate(_ delegate: FlutterPlugin) {} - func addSceneDelegate(_ delegate: any FlutterSceneLifeCycleDelegate) {} - func register(_ factory: FlutterPlatformViewFactory, withId: String, gestureRecognizersBlockingPolicy: FlutterPlatformViewGestureRecognizersBlockingPolicy) {} diff --git a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthHandlerTests.swift b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthHandlerTests.swift index 5b3fb34ed..8dc427b1f 100644 --- a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthHandlerTests.swift @@ -64,7 +64,7 @@ extension WebAuthHandlerTests { let userAgentDictionary = [UserAgentProperty.name.rawValue: "baz", UserAgentProperty.version.rawValue: "qux"] let argumentsDictionary = [Account.key: accountDictionary, UserAgent.key: userAgentDictionary] let expectation = self.expectation(description: "Called client provider") - sut.clientProvider = { account, userAgent in + sut.clientProvider = { (account: Account, userAgent: UserAgent) -> WebAuth in XCTAssertEqual(account.clientId, accountDictionary[AccountProperty.clientId]) XCTAssertEqual(account.domain, accountDictionary[AccountProperty.domain]) XCTAssertEqual(userAgent.name, userAgentDictionary[UserAgentProperty.name]) diff --git a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthLoginMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthLoginMethodHandlerTests.swift index b5f2350d7..b46693c0f 100644 --- a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthLoginMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthLoginMethodHandlerTests.swift @@ -25,7 +25,6 @@ class WebAuthLoginHandlerTests: XCTestCase { } } -// MARK: - Required Arguments Error extension WebAuthLoginHandlerTests { func testProducesErrorWhenRequiredArgumentsAreMissing() { @@ -217,9 +216,26 @@ extension WebAuthLoginHandlerTests { XCTAssertEqual(spySafariProvider.presentationStyle, UIModalPresentationStyle.formSheet) } #endif + + // MARK: useDPoP + + func testEnablesDPoPWhenTrue() { + let value = true + sut.handle(with: arguments(withKey: Argument.useDPoP, value: value)) { _ in } + XCTAssertNotNil(spy.dpop) + } + + func testDoesNotEnableDPoPWhenFalse() { + sut.handle(with: arguments(withKey: Argument.useDPoP, value: false)) { _ in } + XCTAssertNil(spy.dpop) + } + + func testDoesNotEnableDPoPWhenNil() { + sut.handle(with: arguments(without: Argument.useDPoP)) { _ in } + XCTAssertNil(spy.dpop) + } } -// MARK: - Login Result extension WebAuthLoginHandlerTests { func testCallsSDKLoginMethod() { @@ -255,7 +271,6 @@ extension WebAuthLoginHandlerTests { } } -// MARK: - Helpers extension WebAuthLoginHandlerTests { override func arguments() -> [String: Any] { diff --git a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthSpies.swift b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthSpies.swift index 647aad1d7..aec504638 100644 --- a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthSpies.swift +++ b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthSpies.swift @@ -27,6 +27,8 @@ class SpyWebAuth: WebAuth { let url = mockURL var telemetry = Telemetry() var logger: Logger? + var sender: String = "auth0-flutter" + var dpop: DPoP? var loginResult: WebAuthResult = .success(Credentials()) var logoutResult: WebAuthResult = .success(()) @@ -132,6 +134,10 @@ class SpyWebAuth: WebAuth { return self } + func headers(_ headers: [String: String]) -> Self { + return self + } + func start(_ callback: @escaping (WebAuthResult) -> Void) { calledLogin = true callback(loginResult) diff --git a/auth0_flutter/example/lib/example_app.dart b/auth0_flutter/example/lib/example_app.dart index b557c6c9d..a27f8d8f7 100644 --- a/auth0_flutter/example/lib/example_app.dart +++ b/auth0_flutter/example/lib/example_app.dart @@ -126,6 +126,68 @@ class _ExampleAppState extends State { }); } + // DPoP Login Function - Works on Web, Android, and iOS + Future dpopLogin() async { + String output = ''; + + try { + if (kIsWeb) { + // Web: Use popup-based login with DPoP + final auth0WebDPoP = Auth0Web( + dotenv.env['AUTH0_DOMAIN']!, + dotenv.env['AUTH0_CLIENT_ID']!, + useDPoP: true, + ); + + // Initialize SDK + await auth0WebDPoP.onLoad(audience: dotenv.env['AUTH0_AUDIENCE']); + + // Login with popup + final credentials = await auth0WebDPoP.loginWithPopup( + audience: dotenv.env['AUTH0_AUDIENCE'], + ); + + setState(() { + _isLoggedIn = true; + }); + + output = 'DPoP Login Successful!\n\n' + 'Token Type: ${credentials.tokenType}\n' + 'Access Token: ${credentials.accessToken.substring(0, 50)}...\n' + 'ID Token: ${credentials.idToken.substring(0, 50)}...\n' + 'Expires At: ${credentials.expiresAt}'; + } else { + // Mobile (Android/iOS): Use WebAuth with DPoP + final webAuthDPoP = auth0.webAuthentication( + scheme: dotenv.env['AUTH0_CUSTOM_SCHEME'], + ); + + final result = await webAuthDPoP.login( + useDPoP: true, // Enable DPoP for mobile + useHTTPS: true, + audience: dotenv.env['AUTH0_AUDIENCE'], + ); + + setState(() { + _isLoggedIn = true; + }); + + output = 'DPoP Login Successful!\n\n' + 'Token Type: ${result.tokenType}\n' + 'Access Token: ${result.accessToken.substring(0, 50)}...\n' + 'ID Token: ${result.idToken.substring(0, 50)}...'; + } + } catch (e) { + output = 'DPoP Login Failed:\n$e'; + } + + if (!mounted) return; + + setState(() { + _output = output; + }); + } + @override Widget build(final BuildContext context) { return MaterialApp( @@ -146,6 +208,21 @@ class _ExampleAppState extends State { else WebAuthCard( label: 'Web Auth Login', action: webAuthLogin), + const SizedBox(height: 10), + // DPoP Button - Works on Web, Android, and iOS + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, vertical: 12), + ), + onPressed: dpopLogin, + child: const Text( + 'DPoP Login', + style: TextStyle(fontSize: 16), + ), + ), ]), )), SliverFillRemaining( diff --git a/auth0_flutter/example/pubspec.yaml b/auth0_flutter/example/pubspec.yaml index 6a7bf7546..3872ff416 100644 --- a/auth0_flutter/example/pubspec.yaml +++ b/auth0_flutter/example/pubspec.yaml @@ -57,8 +57,8 @@ flutter: # the material Icons class. uses-material-design: true - assets: - - .env + # assets: + # - .env # To add assets to your application, add an assets section, like this: # assets: diff --git a/auth0_flutter/example/web/index.html b/auth0_flutter/example/web/index.html index 57b924e49..abcd7a7aa 100644 --- a/auth0_flutter/example/web/index.html +++ b/auth0_flutter/example/web/index.html @@ -38,21 +38,73 @@ - + + + diff --git a/auth0_flutter/ios/Classes/DPoP/DPoPClearKeyMethodHandler.swift b/auth0_flutter/ios/Classes/DPoP/DPoPClearKeyMethodHandler.swift new file mode 120000 index 000000000..2ee9bbc59 --- /dev/null +++ b/auth0_flutter/ios/Classes/DPoP/DPoPClearKeyMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/DPoP/DPoPClearKeyMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/DPoP/DPoPGetHeadersMethodHandler.swift b/auth0_flutter/ios/Classes/DPoP/DPoPGetHeadersMethodHandler.swift new file mode 120000 index 000000000..21506d926 --- /dev/null +++ b/auth0_flutter/ios/Classes/DPoP/DPoPGetHeadersMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/DPoP/DPoPGetHeadersMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/DPoP/DPoPHandler.swift b/auth0_flutter/ios/Classes/DPoP/DPoPHandler.swift new file mode 120000 index 000000000..0ef26ea83 --- /dev/null +++ b/auth0_flutter/ios/Classes/DPoP/DPoPHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/DPoP/DPoPHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 2c276c80e..16aadb281 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/auth0_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'auth0_flutter' - s.version = '1.14.0' + s.version = '2.0.0-beta.1' s.summary = 'Auth0 SDK for Flutter' s.description = 'Auth0 SDK for Flutter Android and iOS apps.' s.homepage = 'https://auth0.com' @@ -19,9 +19,9 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.10.0' - s.dependency 'JWTDecode', '3.2.0' - s.dependency 'SimpleKeychain', '1.2.0' + s.dependency 'Auth0', '2.14.0' + s.dependency 'JWTDecode', '3.3.0' + s.dependency 'SimpleKeychain', '1.3.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/auth0_flutter/lib/auth0_flutter.dart b/auth0_flutter/lib/auth0_flutter.dart index 27a5bb5ff..b57fb4b81 100644 --- a/auth0_flutter/lib/auth0_flutter.dart +++ b/auth0_flutter/lib/auth0_flutter.dart @@ -18,7 +18,8 @@ export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac ChallengeType, CredentialsManagerException, PasswordlessType, - LocalAuthentication; + LocalAuthentication, + LocalAuthenticationLevel; export 'src/mobile/authentication_api.dart'; export 'src/mobile/credentials_manager.dart'; @@ -54,18 +55,16 @@ class Auth0 { Auth0(final String domain, final String clientId, {final LocalAuthentication? localAuthentication, final CredentialsManager? credentialsManager, - final CredentialsManagerConfiguration? credentialsManagerConfiguration}) + final CredentialsManagerConfiguration? credentialsManagerConfiguration, + final bool useDPoP = false}) : _account = Account(domain, clientId) { _credentialsManager = credentialsManager ?? - DefaultCredentialsManager( - _account, - _userAgent, - localAuthentication: localAuthentication, - credentialsManagerConfiguration:credentialsManagerConfiguration - ); + DefaultCredentialsManager(_account, _userAgent, + localAuthentication: localAuthentication, + credentialsManagerConfiguration: credentialsManagerConfiguration, + useDPoP: useDPoP); } - /// An instance of [AuthenticationApi], the primary interface for interacting /// with the Auth0 Authentication API /// @@ -103,4 +102,70 @@ class Auth0 { {final String? scheme, final bool useCredentialsManager = true}) => WebAuthentication(_account, _userAgent, scheme, useCredentialsManager ? credentialsManager : null); + + /// Generates DPoP (Demonstrating Proof-of-Possession) headers for making + /// authenticated API calls with DPoP-bound tokens. + /// + /// DPoP is a security mechanism that binds access tokens to a specific + /// cryptographic key pair. When making API calls with DPoP-bound tokens, + /// you must include both the access token and a DPoP proof JWT in your + /// request headers. + /// + /// ## Parameters + /// * [url] - The full URL of the API endpoint you're requesting + /// * [method] - The HTTP method (e.g., 'GET', 'POST', 'PUT', 'DELETE') + /// * [accessToken] - The access token obtained from authentication + /// * [tokenType] - The token type, defaults to 'Bearer' + /// + /// ## Returns + /// A map containing two headers: + /// * `authorization`: Contains the token type and access token + /// * `dpop`: Contains the DPoP proof JWT + /// + /// ## Usage example + /// + /// ```dart + /// final auth0 = Auth0('DOMAIN', 'CLIENT_ID'); + /// final headers = await auth0.getDPoPHeaders( + /// url: 'https://api.example.com/resource', + /// method: 'GET', + /// accessToken: credentials.accessToken, + /// ); + /// + /// // Use headers in your HTTP request + /// final response = await http.get( + /// Uri.parse('https://api.example.com/resource'), + /// headers: headers, + /// ); + /// ``` + Future> getDPoPHeaders({ + required final String url, + required final String method, + required final String accessToken, + final String tokenType = 'Bearer', + }) => + Auth0FlutterDPoPPlatform.instance.getDPoPHeaders(DPoPRequest( + options: AuthDPoPHeadersOptions( + url: url, + method: method, + accessToken: accessToken, + tokenType: tokenType))); + + /// Clears the DPoP (Demonstrating Proof-of-Possession) private key from + /// secure storage. + /// + /// This method should be called when logging out to ensure that the DPoP + /// key pair is properly removed from the device's secure storage. This is + /// important for security as it prevents the key from being reused after + /// logout. + /// + /// ## Usage example + /// + /// ```dart + /// final auth0 = Auth0('DOMAIN', 'CLIENT_ID'); + /// // Clear DPoP key on logout + /// await auth0.clearDPoPKey(); + /// ``` + Future clearDPoPKey() => Auth0FlutterDPoPPlatform.instance + .clearDPoPKey(const DPoPRequest(options: EmptyRequestOptions())); } diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index e259fbc38..792ea4926 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -9,25 +9,38 @@ class Auth0Web { final Account _account; final String? _redirectUrl; final CacheLocation? _cacheLocation; + final bool _useDPoP; final UserAgent _userAgent = UserAgent(name: 'auth0-flutter', version: version); /// Creates an instance of the [Auth0Web] client with the provided - /// [domain], [clientId], and optional [redirectUrl] and [cacheLocation] properties. + /// [domain], [clientId], and optional [redirectUrl], [cacheLocation], + /// and [useDPoP] properties. /// - /// [redirectUrl] is used for silent authentication in [onLoad]. - /// [cacheLocation] is used to specify where the SDK should store - /// its authentication state. Defaults to `memory`. Setting this to `localStorage` - /// is often required for seamless silent authentication on page reloads. + /// **Parameters:** /// - /// [domain] and [clientId] are both values that can be retrieved from the - /// **Settings** page of your [Auth0 application](https://manage.auth0.com/#/applications/). + /// * [domain] and [clientId] are both values that can be retrieved + /// from the **Settings** page of your + /// [Auth0 application](https://manage.auth0.com/#/applications/). + /// + /// * [redirectUrl] is used for silent authentication in [onLoad]. + /// + /// * [cacheLocation] specifies where the SDK should store its + /// authentication state. Defaults to `memory`. Setting this to + /// `localStorage` is often required for seamless silent + /// authentication on page reloads. + /// + /// * [useDPoP] enables DPoP for enhanced token security. + /// See README for details. Defaults to `false`. Auth0Web(final String domain, final String clientId, - {final String? redirectUrl, final CacheLocation? cacheLocation}) + {final String? redirectUrl, + final CacheLocation? cacheLocation, + final bool useDPoP = false}) : _account = Account(domain, clientId), _redirectUrl = redirectUrl, - _cacheLocation = cacheLocation; + _cacheLocation = cacheLocation, + _useDPoP = useDPoP; /// Get the app state that was provided during a previous call /// to [loginWithRedirect]. @@ -72,6 +85,7 @@ class Auth0Web { await Auth0FlutterWebPlatform.instance.initialize( ClientOptions( account: _account, + useDPoP: _useDPoP, authorizeTimeoutInSeconds: authorizeTimeoutInSeconds, cacheLocation: cacheLocation ?? _cacheLocation, cookieDomain: cookieDomain, @@ -87,7 +101,7 @@ class Auth0Web { audience: audience, scopes: scopes, parameters: { - if (_redirectUrl != null) 'redirect_uri': _redirectUrl!, + if (_redirectUrl != null) 'redirect_uri': _redirectUrl, ...parameters }), _userAgent); diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 8e776d8fc..9e2a639fe 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -278,10 +278,13 @@ class AuthenticationApi { /// ``` Future userProfile( {required final String accessToken, + final String tokenType = 'Bearer', final Map parameters = const {}}) => Auth0FlutterAuthPlatform.instance.userInfo(_createApiRequest( AuthUserInfoOptions( - accessToken: accessToken, parameters: parameters))); + accessToken: accessToken, + tokenType: tokenType, + parameters: parameters))); /// Registers a new user with the specified [email] address and [password] in /// the specified [connection]. diff --git a/auth0_flutter/lib/src/mobile/credentials_manager.dart b/auth0_flutter/lib/src/mobile/credentials_manager.dart index 5cba026c2..26ee984cf 100644 --- a/auth0_flutter/lib/src/mobile/credentials_manager.dart +++ b/auth0_flutter/lib/src/mobile/credentials_manager.dart @@ -30,12 +30,15 @@ class DefaultCredentialsManager extends CredentialsManager { final UserAgent _userAgent; final LocalAuthentication? _localAuthentication; final CredentialsManagerConfiguration? _credentialsManagerConfiguration; + final bool _useDPoP; DefaultCredentialsManager(this._account, this._userAgent, {final LocalAuthentication? localAuthentication, - final CredentialsManagerConfiguration? credentialsManagerConfiguration}) + final CredentialsManagerConfiguration? credentialsManagerConfiguration, + final bool useDPoP = false}) : _localAuthentication = localAuthentication, - _credentialsManagerConfiguration = credentialsManagerConfiguration; + _credentialsManagerConfiguration = credentialsManagerConfiguration, + _useDPoP = useDPoP; /// Retrieves the credentials from the storage and refreshes them if they have /// already expired. @@ -100,12 +103,14 @@ class DefaultCredentialsManager extends CredentialsManager { Future clearCredentials() => CredentialsManagerPlatform.instance .clearCredentials(_createApiRequest(null)); - CredentialsManagerRequest _createApiRequest< - TOptions extends RequestOptions>(final TOptions? options) => - CredentialsManagerRequest( - account: _account, - options: options, - userAgent: _userAgent, - localAuthentication: _localAuthentication, - credentialsManagerConfiguration: _credentialsManagerConfiguration); + CredentialsManagerRequest + _createApiRequest( + final TOptions? options) => + CredentialsManagerRequest( + account: _account, + options: options, + userAgent: _userAgent, + localAuthentication: _localAuthentication, + credentialsManagerConfiguration: _credentialsManagerConfiguration, + useDPoP: _useDPoP); } diff --git a/auth0_flutter/lib/src/mobile/web_authentication.dart b/auth0_flutter/lib/src/mobile/web_authentication.dart index 66aeb1820..349ccd6d2 100644 --- a/auth0_flutter/lib/src/mobile/web_authentication.dart +++ b/auth0_flutter/lib/src/mobile/web_authentication.dart @@ -77,6 +77,8 @@ class WebAuthentication { /// another allowed browser installed, the allowed browser is used instead /// When the user's default browser is not in the allowlist, and the user has /// no other allowed browser installed, an error is returned + /// * [useDPoP] enables DPoP for enhanced token security. + /// See README for details. Defaults to `false`. Future login( {final String? audience, final Set scopes = const { @@ -94,7 +96,8 @@ class WebAuthentication { final Map parameters = const {}, final IdTokenValidationConfig idTokenValidationConfig = const IdTokenValidationConfig(), - final SafariViewController? safariViewController}) async { + final SafariViewController? safariViewController, + final bool useDPoP = false}) async { final credentials = await Auth0FlutterWebAuthPlatform.instance.login( _createWebAuthRequest(WebAuthLoginOptions( audience: audience, @@ -108,9 +111,11 @@ class WebAuthentication { useHTTPS: useHTTPS, useEphemeralSession: useEphemeralSession, safariViewController: safariViewController, - allowedBrowsers: allowedBrowsers))); + allowedBrowsers: allowedBrowsers, + useDPoP: useDPoP))); await _credentialsManager?.storeCredentials(credentials); + return credentials; } diff --git a/auth0_flutter/lib/src/version.dart b/auth0_flutter/lib/src/version.dart index bf5656dbb..8e5652dbe 100644 --- a/auth0_flutter/lib/src/version.dart +++ b/auth0_flutter/lib/src/version.dart @@ -1 +1 @@ -const String version = '1.14.0'; +const String version = '2.0.0-beta.1'; diff --git a/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart b/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart index 6c2e5b9c1..f556b1c7c 100644 --- a/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart +++ b/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart @@ -116,11 +116,19 @@ class Auth0FlutterPlugin extends Auth0FlutterWebPlatform { interop.PopupLoginOptions(authorizationParams: authParams), popupConfig); + // Use cache-only mode to avoid making a new token request. + // loginWithPopup() internally awaits _requestToken() which caches + // the token (including DPoP tokens) before resolving, so the token + // is guaranteed to be in cache at this point. This ensures we + // return the exact same token that was just obtained, maintaining + // DPoP proof binding consistency. + // See: https://github.com/auth0/auth0-spa-js/blob/main/... return CredentialsExtension.fromWeb(await client.getTokenSilently( interop.GetTokenSilentlyOptions( authorizationParams: JsInteropUtils.stripNulls( interop.GetTokenSilentlyAuthParams( scope: authParams.scope, audience: authParams.audience)), + cacheMode: 'cache-only', detailedResponse: true))); } catch (e) { throw WebExceptionExtension.fromJsObject(JSObject.fromInteropObject(e)); diff --git a/auth0_flutter/lib/src/web/extensions/client_options_extensions.dart b/auth0_flutter/lib/src/web/extensions/client_options_extensions.dart index ade2b67c6..428166f1a 100644 --- a/auth0_flutter/lib/src/web/extensions/client_options_extensions.dart +++ b/auth0_flutter/lib/src/web/extensions/client_options_extensions.dart @@ -21,6 +21,7 @@ extension ClientOptionsExtension on ClientOptions { useFormData: useFormData, useRefreshTokens: useRefreshTokens, useRefreshTokensFallback: useRefreshTokensFallback, + useDpop: useDPoP, authorizationParams: JsInteropUtils.stripNulls( JsInteropUtils.addCustomParams( AuthorizationParams( diff --git a/auth0_flutter/lib/src/web/extensions/credentials_extension.dart b/auth0_flutter/lib/src/web/extensions/credentials_extension.dart index cdcef97b1..6632fe9dd 100644 --- a/auth0_flutter/lib/src/web/extensions/credentials_extension.dart +++ b/auth0_flutter/lib/src/web/extensions/credentials_extension.dart @@ -20,6 +20,6 @@ extension CredentialsExtension on Credentials { user: user, refreshToken: webCredentials.refresh_token, scopes: {...webCredentials.scope?.splitBySingleSpace() ?? []}, - tokenType: 'Bearer'); + tokenType: webCredentials.token_type ?? 'Bearer'); } } diff --git a/auth0_flutter/lib/src/web/js_interop.dart b/auth0_flutter/lib/src/web/js_interop.dart index 8b5f81328..d1adc671c 100644 --- a/auth0_flutter/lib/src/web/js_interop.dart +++ b/auth0_flutter/lib/src/web/js_interop.dart @@ -106,6 +106,7 @@ extension type Auth0ClientOptions._(JSObject _) implements JSObject { final bool? useFormData, final bool? useRefreshTokens, final bool? useRefreshTokensFallback, + final bool? useDpop, final AuthorizationParams? authorizationParams}); } @@ -144,13 +145,15 @@ extension type WebCredentials._(JSObject _) implements JSObject { external JSNumber expires_in; external String? get refresh_token; external String? get scope; + external String? get token_type; external factory WebCredentials( {final String access_token, final String id_token, final JSNumber expires_in, final String? refresh_token, - final String? scope}); + final String? scope, + final String? token_type}); } @JS() diff --git a/auth0_flutter/macos/Classes/DPoP/DPoPClearKeyMethodHandler.swift b/auth0_flutter/macos/Classes/DPoP/DPoPClearKeyMethodHandler.swift new file mode 120000 index 000000000..2ee9bbc59 --- /dev/null +++ b/auth0_flutter/macos/Classes/DPoP/DPoPClearKeyMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/DPoP/DPoPClearKeyMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/DPoP/DPoPGetHeadersMethodHandler.swift b/auth0_flutter/macos/Classes/DPoP/DPoPGetHeadersMethodHandler.swift new file mode 120000 index 000000000..21506d926 --- /dev/null +++ b/auth0_flutter/macos/Classes/DPoP/DPoPGetHeadersMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/DPoP/DPoPGetHeadersMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/DPoP/DPoPHandler.swift b/auth0_flutter/macos/Classes/DPoP/DPoPHandler.swift new file mode 120000 index 000000000..0ef26ea83 --- /dev/null +++ b/auth0_flutter/macos/Classes/DPoP/DPoPHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/DPoP/DPoPHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 2c276c80e..16aadb281 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/auth0_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'auth0_flutter' - s.version = '1.14.0' + s.version = '2.0.0-beta.1' s.summary = 'Auth0 SDK for Flutter' s.description = 'Auth0 SDK for Flutter Android and iOS apps.' s.homepage = 'https://auth0.com' @@ -19,9 +19,9 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.10.0' - s.dependency 'JWTDecode', '3.2.0' - s.dependency 'SimpleKeychain', '1.2.0' + s.dependency 'Auth0', '2.14.0' + s.dependency 'JWTDecode', '3.3.0' + s.dependency 'SimpleKeychain', '1.3.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index 577bbbfa7..6bfa4de8e 100644 --- a/auth0_flutter/pubspec.yaml +++ b/auth0_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: auth0_flutter description: Auth0 SDK for Flutter. Easily integrate Auth0 into Android / iOS Flutter apps. -version: 1.14.0 +version: 2.0.0-beta.1 homepage: https://github.com/auth0/auth0-flutter environment: @@ -8,7 +8,7 @@ environment: flutter: ">=3.24.0" dependencies: - auth0_flutter_platform_interface: ^1.14.0 + auth0_flutter_platform_interface: ^2.0.0-beta.1 flutter: sdk: flutter flutter_web_plugins: diff --git a/auth0_flutter/test/mobile/authentication_api_test.dart b/auth0_flutter/test/mobile/authentication_api_test.dart index 1d81fdb7b..fdcf69545 100644 --- a/auth0_flutter/test/mobile/authentication_api_test.dart +++ b/auth0_flutter/test/mobile/authentication_api_test.dart @@ -314,5 +314,34 @@ void main() { verify(mockedPlatform.userInfo(captureAny)).captured.single; expect(verificationResult.options.parameters, isEmpty); }); + + test('defaults tokenType to Bearer when not specified', () async { + when(mockedPlatform.userInfo(any)) + .thenAnswer((final _) async => const UserProfile(sub: 'sub')); + + await Auth0('test-domain', 'test-clientId') + .api + .userProfile(accessToken: 'test-token'); + + final verificationResult = + verify(mockedPlatform.userInfo(captureAny)).captured.single; + expect(verificationResult.options.tokenType, 'Bearer'); + }); + + test('passes through custom tokenType to the platform', () async { + when(mockedPlatform.userInfo(any)) + .thenAnswer((final _) async => const UserProfile(sub: 'sub')); + + await Auth0('test-domain', 'test-clientId') + .api + .userProfile(accessToken: 'test-token', tokenType: 'DPoP'); + + final verificationResult = + verify(mockedPlatform.userInfo(captureAny)).captured.single; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + expect(verificationResult.options?.accessToken, 'test-token'); + expect(verificationResult.options?.tokenType, 'DPoP'); + }); }); } diff --git a/auth0_flutter/test/mobile/web_authentication_test.dart b/auth0_flutter/test/mobile/web_authentication_test.dart index 3cc8be2ef..dd8f48854 100644 --- a/auth0_flutter/test/mobile/web_authentication_test.dart +++ b/auth0_flutter/test/mobile/web_authentication_test.dart @@ -284,4 +284,380 @@ void main() { verify(mockCm.clearCredentials()).called(1); }); }); + + group('DPoP Authentication', () { + group('login with DPoP', () { + test('passes useDPoP parameter to platform when enabled', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + }); + + test('passes useDPoP parameter to platform when disabled', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, false); + }); + + test('defaults useDPoP to false when not specified', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().login(); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, false); + }); + + test('passes useDPoP with other authentication parameters', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().login( + useDPoP: true, + audience: 'test-audience', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_123'); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.audience, 'test-audience'); + expect( + verificationResult.options.scopes, {'openid', 'profile', 'email'}); + expect(verificationResult.options.organizationId, 'org_123'); + }); + + test('saves DPoP credentials to credentials manager on success', + () async { + final dpopLoginResult = Credentials.fromMap({ + 'accessToken': 'dpop-access-token', + 'idToken': 'dpop-id-token', + 'refreshToken': 'dpop-refresh-token', + 'expiresAt': DateTime.now().toIso8601String(), + 'scopes': ['openid', 'profile'], + 'userProfile': {'sub': '456', 'name': 'DPoP User'}, + 'tokenType': 'DPoP' + }); + + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => dpopLoginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + final result = await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true); + + final verificationResult = + verify(mockedCMPlatform.saveCredentials(captureAny)).captured.single + as CredentialsManagerRequest; + + expect(verificationResult.options?.credentials.accessToken, + 'dpop-access-token'); + expect(verificationResult.options?.credentials.tokenType, 'DPoP'); + expect(result.tokenType, 'DPoP'); + }); + + test( + 'does not save DPoP credentials when credentials manager is disabled', + () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication(useCredentialsManager: false) + .login(useDPoP: true); + + verifyNever(mockedCMPlatform.saveCredentials(any)); + }); + + test('passes useDPoP with redirectUrl parameter', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true, redirectUrl: 'https://example.com/callback'); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.redirectUrl, + 'https://example.com/callback'); + }); + + test('passes useDPoP with useHTTPS parameter', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true, useHTTPS: true); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.useHTTPS, true); + }); + + test('passes useDPoP with useEphemeralSession parameter', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true, useEphemeralSession: true); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.useEphemeralSession, true); + }); + + test('passes useDPoP with custom parameters', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true, parameters: {'custom_param': 'custom_value'}); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.parameters, + {'custom_param': 'custom_value'}); + }); + + test('passes useDPoP with invitationUrl parameter', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().login( + useDPoP: true, + invitationUrl: 'https://example.com/invite?ticket=abc123'); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.invitationUrl, + 'https://example.com/invite?ticket=abc123'); + }); + + test('passes useDPoP with safariViewController parameter (iOS)', + () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().login( + useDPoP: true, + safariViewController: const SafariViewController( + presentationStyle: + SafariViewControllerPresentationStyle.fullScreen)); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect( + verificationResult.options.safariViewController, + const SafariViewController( + presentationStyle: + SafariViewControllerPresentationStyle.fullScreen)); + }); + + test('passes useDPoP with allowedBrowsers parameter (Android)', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().login( + useDPoP: true, + allowedBrowsers: ['com.android.chrome', 'org.mozilla.firefox']); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.allowedBrowsers, + ['com.android.chrome', 'org.mozilla.firefox']); + }); + + test('uses custom credentials manager with DPoP', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + final mockCm = MockCredentialsManager(); + when(mockCm.storeCredentials(any)).thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId', credentialsManager: mockCm) + .webAuthentication() + .login(useDPoP: true); + + verifyNever(mockedCMPlatform.saveCredentials(any)); + + final verificationResult = verify(mockCm.storeCredentials(captureAny)) + .captured + .single as Credentials; + + expect(verificationResult.accessToken, + TestPlatform.loginResult.accessToken); + + final loginRequest = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + expect(loginRequest.options.useDPoP, true); + }); + }); + + group('DPoP Integration Tests', () { + test('returns correct credentials with DPoP token type', () async { + final dpopCredentials = Credentials.fromMap({ + 'accessToken': 'dpop-token', + 'idToken': 'id-token', + 'refreshToken': 'refresh-token', + 'expiresAt': + DateTime.now().add(const Duration(hours: 1)).toIso8601String(), + 'scopes': ['openid', 'profile', 'email'], + 'userProfile': {'sub': 'user123', 'name': 'Test User'}, + 'tokenType': 'DPoP' + }); + + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => dpopCredentials); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + final result = await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true); + + expect(result.accessToken, 'dpop-token'); + expect(result.tokenType, 'DPoP'); + expect(result.user.sub, 'user123'); + }); + + test('DPoP works with full authentication flow', () async { + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + final result = await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login( + useDPoP: true, + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email', 'offline_access'}, + organizationId: 'org_123', + redirectUrl: 'myapp://callback', + useHTTPS: true, + parameters: {'connection': 'google-oauth2'}); + + final verificationResult = verify(mockedPlatform.login(captureAny)) + .captured + .single as WebAuthRequest; + + expect(verificationResult.options.useDPoP, true); + expect(verificationResult.options.audience, 'https://api.example.com'); + expect(verificationResult.options.scopes, + {'openid', 'profile', 'email', 'offline_access'}); + expect(verificationResult.options.organizationId, 'org_123'); + expect(verificationResult.options.redirectUrl, 'myapp://callback'); + expect(verificationResult.options.useHTTPS, true); + expect(verificationResult.options.parameters, + {'connection': 'google-oauth2'}); + expect(result, TestPlatform.loginResult); + }); + + test('DPoP credentials are stored correctly', () async { + final dpopCredentials = Credentials.fromMap({ + 'accessToken': 'dpop-access-token', + 'idToken': 'dpop-id-token', + 'refreshToken': 'dpop-refresh-token', + 'expiresAt': + DateTime.now().add(const Duration(hours: 2)).toIso8601String(), + 'scopes': ['openid', 'profile', 'email'], + 'userProfile': {'sub': 'dpop-user', 'name': 'DPoP Test'}, + 'tokenType': 'DPoP' + }); + + when(mockedPlatform.login(any)) + .thenAnswer((final _) async => dpopCredentials); + when(mockedCMPlatform.saveCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId') + .webAuthentication() + .login(useDPoP: true); + + final savedCredentials = + verify(mockedCMPlatform.saveCredentials(captureAny)).captured.single + as CredentialsManagerRequest; + + expect(savedCredentials.options?.credentials.tokenType, 'DPoP'); + expect(savedCredentials.options?.credentials.accessToken, + 'dpop-access-token'); + expect(savedCredentials.account.domain, 'test-domain'); + }); + }); + }); } diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index b855d4399..69d2ab9b8 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -72,20 +72,20 @@ void main() { }); test('handleRedirectCallback is called on load when auth params exist in URL', - () async { - final interop.RedirectLoginResult mockRedirectResult = + () async { + final interop.RedirectLoginResult mockRedirectResult = interop.RedirectLoginResult(); - when(mockClientProxy.isAuthenticated()) - .thenAnswer((final _) => Future.value(false)); - when(mockClientProxy.handleRedirectCallback()) - .thenAnswer((final _) => Future.value(mockRedirectResult)); + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) => Future.value(false)); + when(mockClientProxy.handleRedirectCallback()) + .thenAnswer((final _) => Future.value(mockRedirectResult)); - plugin.urlSearchProvider = () => '?code=abc&state=123'; - await auth0.onLoad(); - verify(mockClientProxy.handleRedirectCallback()); - verifyNever(mockClientProxy.checkSession()); - }); + plugin.urlSearchProvider = () => '?code=abc&state=123'; + await auth0.onLoad(); + verify(mockClientProxy.handleRedirectCallback()); + verifyNever(mockClientProxy.checkSession()); + }); test('handleRedirectCallback captures appState that was passed', () async { final Map appState = { @@ -93,7 +93,7 @@ void main() { }; final interop.RedirectLoginResult mockRedirectResult = - interop.RedirectLoginResult( + interop.RedirectLoginResult( appState: appState.jsify(), ); @@ -123,7 +123,7 @@ void main() { }; final interop.RedirectLoginResult mockRedirectResult = - interop.RedirectLoginResult( + interop.RedirectLoginResult( appState: appState.jsify(), ); @@ -145,22 +145,22 @@ void main() { }); test('onLoad throws the correct exception from handleRedirectCallback', - () async { - when(mockClientProxy.isAuthenticated()) - .thenAnswer((final _) => Future.value(false)); + () async { + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) => Future.value(false)); - when(mockClientProxy.handleRedirectCallback()) - .thenThrow(createJsException('test', 'test exception')); + when(mockClientProxy.handleRedirectCallback()) + .thenThrow(createJsException('test', 'test exception')); - plugin.urlSearchProvider = () => '?code=abc&state=123'; + plugin.urlSearchProvider = () => '?code=abc&state=123'; - expect( - () async => auth0.onLoad(), - throwsA(predicate((final e) => + expect( + () async => auth0.onLoad(), + throwsA(predicate((final e) => e is WebException && - e.code == 'test' && - e.message == 'test exception'))); - }); + e.code == 'test' && + e.message == 'test exception'))); + }); test('loginWithRedirect supports appState parameter', () async { when(mockClientProxy.isAuthenticated()) @@ -309,9 +309,9 @@ void main() { .thenThrow(createJsException('test', 'test exception')); expect( - () async => auth0.credentials(), + () async => auth0.credentials(), throwsA(predicate((final e) => - e is WebException && + e is WebException && e.code == 'test' && e.message == 'test exception'))); }); @@ -374,7 +374,7 @@ void main() { .thenAnswer((final _) => Future.value(webCredentials)); final credentials = - await auth0.loginWithPopup(parameters: {'screen_hint': 'signup'}); + await auth0.loginWithPopup(parameters: {'screen_hint': 'signup'}); expect(credentials, isNotNull); @@ -385,33 +385,33 @@ void main() { }); test('loginWithPopup throws the correct exception from js.loginWithPopup', - () async { - when(mockClientProxy.loginWithPopup(any, any)) - .thenThrow(createJsException('test', 'test exception')); + () async { + when(mockClientProxy.loginWithPopup(any, any)) + .thenThrow(createJsException('test', 'test exception')); - expect( - () async => auth0.loginWithPopup(), - throwsA(predicate((final e) => + expect( + () async => auth0.loginWithPopup(), + throwsA(predicate((final e) => e is WebException && - e.code == 'test' && - e.message == 'test exception'))); - }); + e.code == 'test' && + e.message == 'test exception'))); + }); test('loginWithPopup throws the correct exception from getTokenSilently', - () async { - when(mockClientProxy.loginWithPopup(any, any)) - .thenAnswer((final _) => Future.value()); + () async { + when(mockClientProxy.loginWithPopup(any, any)) + .thenAnswer((final _) => Future.value()); - when(mockClientProxy.getTokenSilently(any)) - .thenThrow(createJsException('test', 'test exception')); + when(mockClientProxy.getTokenSilently(any)) + .thenThrow(createJsException('test', 'test exception')); - expect( - () async => auth0.loginWithPopup(), - throwsA(predicate((final e) => + expect( + () async => auth0.loginWithPopup(), + throwsA(predicate((final e) => e is WebException && - e.code == 'test' && - e.message == 'test exception'))); - }); + e.code == 'test' && + e.message == 'test exception'))); + }); group('invitationUrl handling', () { const fullInvitationUrl = @@ -423,48 +423,47 @@ void main() { group('loginWithRedirect', () { setUp(() { when(mockClientProxy.loginWithRedirect(any)) - .thenAnswer((_) => Future.value()); + .thenAnswer((final _) => Future.value()); }); test('correctly parses the ticket ID from a full invitation URL', - () async { - await auth0.loginWithRedirect(invitationUrl: fullInvitationUrl); + () async { + await auth0.loginWithRedirect(invitationUrl: fullInvitationUrl); - final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) - .captured - .single as interop.RedirectLoginOptions; - expect(captured.authorizationParams!.invitation, invitationId); - }); + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams!.invitation, invitationId); + }); - test('correctly uses the ticket ID when it is passed directly', - () async { - await auth0.loginWithRedirect(invitationUrl: invitationId); + test('correctly uses the ticket ID when it is passed directly', () async { + await auth0.loginWithRedirect(invitationUrl: invitationId); - final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) - .captured - .single as interop.RedirectLoginOptions; - expect(captured.authorizationParams!.invitation, invitationId); - }); + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams!.invitation, invitationId); + }); test('uses the original string as ticket ID when URL parsing fails', - () async { - await auth0.loginWithRedirect(invitationUrl: invalidUrl); - final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) - .captured - .single as interop.RedirectLoginOptions; - expect(captured.authorizationParams!.invitation, invalidUrl); - }); + () async { + await auth0.loginWithRedirect(invitationUrl: invalidUrl); + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams!.invitation, invalidUrl); + }); test( - 'returns null for the ticket when a valid URL without the parameter is passed', - () async { - await auth0.loginWithRedirect(invitationUrl: urlWithoutInvitation); + 'returns null for the ticket when a valid URL without the ' + 'parameter is passed', () async { + await auth0.loginWithRedirect(invitationUrl: urlWithoutInvitation); - final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) - .captured - .single as interop.RedirectLoginOptions; - expect(captured.authorizationParams!.invitation, isNull); - }); + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams!.invitation, isNull); + }); test('passes null when invitationUrl is an empty string', () async { await auth0.loginWithRedirect(invitationUrl: ''); @@ -478,42 +477,524 @@ void main() { group('loginWithPopup', () { setUp(() { when(mockClientProxy.loginWithPopup(any, any)) - .thenAnswer((_) => Future.value()); + .thenAnswer((final _) => Future.value()); when(mockClientProxy.getTokenSilently(any)) - .thenAnswer((_) => Future.value(webCredentials)); + .thenAnswer((final _) => Future.value(webCredentials)); }); test('correctly parses the ticket ID from a full invitation URL', - () async { - await auth0.loginWithPopup(invitationUrl: fullInvitationUrl); - - final captured = - verify(mockClientProxy.loginWithPopup(captureAny, any)) - .captured - .single as interop.PopupLoginOptions; - expect(captured.authorizationParams!.invitation, invitationId); - }); - - test('correctly uses the ticket ID when it is passed directly', - () async { - await auth0.loginWithPopup(invitationUrl: invitationId); - - final captured = - verify(mockClientProxy.loginWithPopup(captureAny, any)) - .captured - .single as interop.PopupLoginOptions; - expect(captured.authorizationParams!.invitation, invitationId); - }); + () async { + await auth0.loginWithPopup(invitationUrl: fullInvitationUrl); + + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams!.invitation, invitationId); + }); + + test('correctly uses the ticket ID when it is passed directly', () async { + await auth0.loginWithPopup(invitationUrl: invitationId); + + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams!.invitation, invitationId); + }); test('passes null when invitationUrl is not provided', () async { await auth0.loginWithPopup(); - final captured = - verify(mockClientProxy.loginWithPopup(captureAny, any)) + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) .captured .single as interop.PopupLoginOptions; expect(captured.authorizationParams!.invitation, isNull); }); }); }); + + group('DPoP Authentication', () { + final auth0WithDPoP = + Auth0Web('test-domain', 'test-client-id', useDPoP: true); + + setUp(() { + plugin = Auth0FlutterPlugin(); + plugin.clientProxy = mockClientProxy; + plugin.urlSearchProvider = () => null; + Auth0FlutterWebPlatform.instance = plugin; + reset(mockClientProxy); + }); + + group('Constructor with DPoP', () { + test('creates Auth0Web instance with DPoP enabled', () { + final auth0DPoP = + Auth0Web('test-domain', 'test-client-id', useDPoP: true); + expect(auth0DPoP, isNotNull); + }); + + test('creates Auth0Web instance with DPoP disabled by default', () { + final auth0NoDPoP = Auth0Web('test-domain', 'test-client-id'); + expect(auth0NoDPoP, isNotNull); + }); + + test('creates Auth0Web instance with explicit DPoP false', () { + final auth0NoDPoP = + Auth0Web('test-domain', 'test-client-id'); + expect(auth0NoDPoP, isNotNull); + }); + }); + + group('onLoad with DPoP', () { + test('onLoad is called with DPoP and authenticated user', () async { + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) async => true); + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0WithDPoP.onLoad(); + + expect(result?.accessToken, jwt); + expect(result?.idToken, jwt); + expect(result?.refreshToken, jwt); + expect(result?.user.sub, jwtPayload['sub']); + expect(result?.scopes, {'openid', 'read_messages'}); + verify(mockClientProxy.checkSession()); + }); + + test('onLoad is called with DPoP without authenticated user', () async { + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) => Future.value(false)); + + final result = await auth0WithDPoP.onLoad(); + + expect(result, null); + verify(mockClientProxy.checkSession()); + }); + + test('onLoad with DPoP handles audience parameter', () async { + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) async => true); + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = + await auth0WithDPoP.onLoad(audience: 'https://test-api.com'); + + expect(result?.accessToken, jwt); + verify(mockClientProxy.getTokenSilently(any)).called(1); + }); + }); + + group('loginWithPopup with DPoP', () { + setUp(() { + when(mockClientProxy.loginWithPopup(any, any)) + .thenAnswer((final _) => Future.value()); + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + }); + + test('loginWithPopup with DPoP returns valid credentials', () async { + final result = await auth0WithDPoP.loginWithPopup(); + + expect(result.accessToken, jwt); + expect(result.idToken, jwt); + expect(result.refreshToken, jwt); + expect(result.user.sub, jwtPayload['sub']); + verify(mockClientProxy.loginWithPopup(any, any)); + }); + + test('loginWithPopup with DPoP and audience parameter', () async { + const testAudience = 'https://DpopFlutterTest/'; + final result = + await auth0WithDPoP.loginWithPopup(audience: testAudience); + + expect(result.accessToken, jwt); + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams?.audience, testAudience); + }); + + test('loginWithPopup with DPoP and custom scopes', () async { + const testScopes = {'openid', 'profile', 'email', 'read:messages'}; + await auth0WithDPoP.loginWithPopup(scopes: testScopes); + + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams?.scope, testScopes.join(' ')); + }); + + test('loginWithPopup with DPoP handles organization parameter', () async { + const testOrg = 'org_123456'; + await auth0WithDPoP + .loginWithPopup(parameters: {'organization': testOrg}); + + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams?.organization, testOrg); + }); + + test('loginWithPopup with DPoP and custom parameters', () async { + const testRedirectUrl = 'http://localhost:3002'; + await auth0WithDPoP + .loginWithPopup(parameters: {'redirect_uri': testRedirectUrl}); + + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams?.redirect_uri, testRedirectUrl); + }); + + test( + 'loginWithPopup with DPoP throws WebAuthenticationException on error', + () async { + final jsError = createJsException('login_required', 'Login required'); + when(mockClientProxy.loginWithPopup(any, any)).thenThrow(jsError); + + expect( + auth0WithDPoP.loginWithPopup, + throwsA(predicate( + (final e) => e is WebException && e.code == 'login_required')), + ); + }); + }); + + group('loginWithRedirect with DPoP', () { + setUp(() { + when(mockClientProxy.loginWithRedirect(any)) + .thenAnswer((final _) => Future.value()); + }); + + test('loginWithRedirect with DPoP is called successfully', () async { + await auth0WithDPoP.loginWithRedirect(); + verify(mockClientProxy.loginWithRedirect(any)); + }); + + test('loginWithRedirect with DPoP and audience parameter', () async { + const testAudience = 'https://DpopFlutterTest/'; + await auth0WithDPoP.loginWithRedirect(audience: testAudience); + + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams?.audience, testAudience); + }); + + test('loginWithRedirect with DPoP and redirectUrl parameter', () async { + const testRedirectUrl = 'http://localhost:3002'; + await auth0WithDPoP.loginWithRedirect(redirectUrl: testRedirectUrl); + + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams?.redirect_uri, testRedirectUrl); + }); + + test('loginWithRedirect with DPoP and custom scopes', () async { + const testScopes = {'openid', 'profile', 'offline_access'}; + await auth0WithDPoP.loginWithRedirect(scopes: testScopes); + + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams?.scope, testScopes.join(' ')); + }); + }); + + group('logout with DPoP', () { + setUp(() { + when(mockClientProxy.logout(any)) + .thenAnswer((final _) => Future.value()); + }); + + test('logout with DPoP is called successfully', () async { + await auth0WithDPoP.logout(); + verify(mockClientProxy.logout(any)); + }); + + test('logout with DPoP and returnToUrl parameter', () async { + const returnUrl = 'http://localhost:3002'; + await auth0WithDPoP.logout(returnToUrl: returnUrl); + + final captured = verify(mockClientProxy.logout(captureAny)) + .captured + .single as interop.LogoutOptions; + expect(captured.logoutParams?.returnTo, returnUrl); + }); + + test('logout with DPoP throws WebAuthenticationException on error', + () async { + final jsError = createJsException('logout_error', 'Logout failed'); + when(mockClientProxy.logout(any)).thenThrow(jsError); + + expect(auth0WithDPoP.logout, throwsA(anything)); + }); + }); + + group('getTokenSilently with DPoP', () { + setUp(() { + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + }); + + test('getTokenSilently with DPoP returns valid credentials', () async { + final result = await auth0WithDPoP.credentials(); + + expect(result.accessToken, jwt); + expect(result.idToken, jwt); + expect(result.refreshToken, jwt); + verify(mockClientProxy.getTokenSilently(any)); + }); + + test('getTokenSilently with DPoP and audience parameter', () async { + const testAudience = 'https://DpopFlutterTest/'; + await auth0WithDPoP.credentials(audience: testAudience); + + final captured = verify(mockClientProxy.getTokenSilently(captureAny)) + .captured + .single as interop.GetTokenSilentlyOptions; + expect(captured.authorizationParams?.audience, testAudience); + }); + + test('getTokenSilently with DPoP throws ApiException on error', () async { + final jsError = + createJsException('consent_required', 'Consent required'); + when(mockClientProxy.getTokenSilently(any)).thenThrow(jsError); + + expect( + auth0WithDPoP.credentials, + throwsA(predicate( + (final e) => e is WebException && e.code == 'consent_required')), + ); + }); + }); + + group('DPoP Token Verification', () { + test('verifies DPoP token type is included in response', () async { + when(mockClientProxy.loginWithPopup(any, any)) + .thenAnswer((final _) => Future.value()); + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0WithDPoP.loginWithPopup( + audience: 'https://DpopFlutterTest/', + ); + + expect(result.accessToken, isNotNull); + expect(result.accessToken, isNotEmpty); + expect(result.accessToken.split('.').length, 3); + }); + + test('verifies credentials contain all required fields with DPoP', + () async { + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0WithDPoP.credentials(); + + expect(result.accessToken, isNotNull); + expect(result.idToken, isNotNull); + expect(result.refreshToken, isNotNull); + expect(result.user, isNotNull); + expect(result.user.sub, isNotEmpty); + expect(result.scopes, isNotEmpty); + }); + }); + + group('DPoP Error Handling', () { + test('handles invalid DPoP configuration error', () async { + final jsError = + createJsException('invalid_request', 'Invalid DPoP configuration'); + when(mockClientProxy.loginWithPopup(any, any)).thenThrow(jsError); + + expect( + auth0WithDPoP.loginWithPopup, + throwsA(predicate((final e) => + e is WebException && e.code == 'AUTHENTICATION_ERROR')), + ); + }); + + test('handles DPoP proof validation error', () async { + final jsError = createJsException( + 'invalid_dpop_proof', 'DPoP proof validation failed'); + when(mockClientProxy.getTokenSilently(any)).thenThrow(jsError); + + expect( + auth0WithDPoP.credentials, + throwsA(predicate((final e) => + e is WebException && e.code == 'invalid_dpop_proof')), + ); + }); + + test('handles network error during DPoP login', () async { + final jsError = + createJsException('network_error', 'Network request failed'); + when(mockClientProxy.loginWithPopup(any, any)).thenThrow(jsError); + + expect( + auth0WithDPoP.loginWithPopup, + throwsA(predicate( + (final e) => e is WebException && e.code == 'network_error')), + ); + }); + + test('handles missing DPoP nonce error', () async { + final jsError = + createJsException('use_dpop_nonce', 'DPoP nonce required'); + when(mockClientProxy.getTokenSilently(any)).thenThrow(jsError); + + expect( + auth0WithDPoP.credentials, + throwsA(predicate((final e) => + e is WebException && e.code == 'use_dpop_nonce')), + ); + }); + + test('handles DPoP replay attack detection', () async { + final jsError = createJsException( + 'invalid_dpop_proof', 'DPoP proof has been used before'); + when(mockClientProxy.loginWithPopup(any, any)).thenThrow(jsError); + + expect( + auth0WithDPoP.loginWithPopup, + throwsA(predicate((final e) => + e is WebException && e.code == 'invalid_dpop_proof')), + ); + }); + }); + + group('DPoP Integration Tests', () { + test('DPoP instance is correctly initialized with useDPoP flag', () { + final dpopAuth0 = + Auth0Web('test-domain', 'test-client-id', useDPoP: true); + expect(dpopAuth0, isNotNull); + }); + + test('Non-DPoP instance does not have DPoP enabled', () { + final regularAuth0 = + Auth0Web('test-domain', 'test-client-id'); + expect(regularAuth0, isNotNull); + }); + + test('DPoP loginWithPopup with custom audience', () async { + const customAudience = 'https://custom-api.example.com/'; + when(mockClientProxy.loginWithPopup(any, any)) + .thenAnswer((final _) => Future.value()); + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0WithDPoP.loginWithPopup(audience: customAudience); + + final captured = verify(mockClientProxy.loginWithPopup(captureAny, any)) + .captured + .single as interop.PopupLoginOptions; + expect(captured.authorizationParams?.audience, customAudience); + }); + + test('DPoP loginWithRedirect with custom audience', () async { + const customAudience = 'https://custom-api.example.com/'; + when(mockClientProxy.loginWithRedirect(any)) + .thenAnswer((final _) => Future.value()); + + await auth0WithDPoP.loginWithRedirect(audience: customAudience); + + final captured = verify(mockClientProxy.loginWithRedirect(captureAny)) + .captured + .single as interop.RedirectLoginOptions; + expect(captured.authorizationParams?.audience, customAudience); + }); + + test('DPoP credentials with cacheMode parameter', () async { + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0WithDPoP.credentials(cacheMode: CacheMode.on); + + final captured = verify(mockClientProxy.getTokenSilently(captureAny)) + .captured + .single as interop.GetTokenSilentlyOptions; + expect(captured.cacheMode, 'on'); + }); + + test('DPoP onLoad initializes correctly', () async { + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) => Future.value(false)); + when(mockClientProxy.checkSession()) + .thenAnswer((final _) => Future.value()); + + final result = await auth0WithDPoP.onLoad(); + + expect(result, isNull); + verify(mockClientProxy.checkSession()); + }); + + test('DPoP onLoad returns credentials when authenticated', () async { + when(mockClientProxy.isAuthenticated()) + .thenAnswer((final _) => Future.value(true)); + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0WithDPoP.onLoad(); + + expect(result, isNotNull); + expect(result?.accessToken, jwt); + verify(mockClientProxy.checkSession()); + }); + }); + + group('DPoP Token Management', () { + test('DPoP credentials refresh with cacheMode off', () async { + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0WithDPoP.credentials( + cacheMode: CacheMode.off, + ); + + expect(result.accessToken, jwt); + final captured = verify(mockClientProxy.getTokenSilently(captureAny)) + .captured + .single as interop.GetTokenSilentlyOptions; + expect(captured.cacheMode, 'off'); + }); + + test('DPoP handles token expiration gracefully', () async { + final expiredCredentials = interop.WebCredentials( + access_token: jwt, + id_token: jwt, + refresh_token: jwt, + scope: 'openid', + expires_in: (-3600).toJS, // Expired + ); + + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(expiredCredentials)); + + final result = await auth0WithDPoP.credentials(); + + expect(result.accessToken, jwt); + verify(mockClientProxy.getTokenSilently(any)); + }); + + test('DPoP credentials with multiple scopes', () async { + final multiScopeCredentials = interop.WebCredentials( + access_token: jwt, + id_token: jwt, + refresh_token: jwt, + scope: 'openid profile email read:messages write:posts', + expires_in: 0.toJS, + ); + + when(mockClientProxy.getTokenSilently(any)) + .thenAnswer((final _) => Future.value(multiScopeCredentials)); + + final result = await auth0WithDPoP.credentials(); + + expect(result.accessToken, jwt); + expect(result.scopes, + {'openid', 'profile', 'email', 'read:messages', 'write:posts'}); + }); + }); + }); } diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index df7192f37..c64107b08 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -282,10 +282,11 @@ web::json::value exchangeCodeForTokens( body[U("code")] = web::json::value::string(utility::conversions::to_string_t(code)); body[U("redirect_uri")] = web::json::value::string(utility::conversions::to_string_t(redirectUri)); body[U("code_verifier")] = web::json::value::string(utility::conversions::to_string_t(codeVerifier)); - DebugPrint("codeVerifier = " + codeVerifier); - DebugPrint("redirect_uri = " + redirectUri); + DebugPrint("codeVerifier = " + codeVerifier); + DebugPrint("redirect_uri = " + redirectUri); request.set_body(body); - +DebugPrint("➡️ POST https://" + domain + "/oauth/token"); +DebugPrint("Request body: " + utility::conversions::to_utf8string(body.serialize())); auto response = client.request(request).get(); // ---- Debug: status & headers ---- @@ -409,6 +410,7 @@ void Auth0FlutterPlugin::HandleMethodCall( } std::string redirectUri = "auth0flutter://callback"; +// authUrl = https://int-dx-enterprise-test.us.auth0.com/authorize?response_type=code&client_id=GGUVoHL5nseaacSzqB810HWYGHZI34m8&redirect_uri=auth0flutter://callback&scope=openid%20profile%20email&code_challenge=JnkpdGGqlvYT_BiinnxwrVK6ocB1PtYEERW4Akttaw0&code_challenge_method=S256 try { diff --git a/auth0_flutter_platform_interface/CHANGELOG.md b/auth0_flutter_platform_interface/CHANGELOG.md index 7e24a31c3..f3c4b75ee 100644 --- a/auth0_flutter_platform_interface/CHANGELOG.md +++ b/auth0_flutter_platform_interface/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [afpi-v2.0.0-beta.1](https://github.com/auth0/auth0-flutter/tree/afpi-v2.0.0-beta.1) (2025-12-10) +[Full Changelog](https://github.com/auth0/auth0-flutter/compare/afpi-v1.14.0...afpi-v2.0.0-beta.1) + +**Added** +- Docs: Add v2.0.0 migration notice and update documentation [\#692](https://github.com/auth0/auth0-flutter/pull/692) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- Adding DPoP feature for flutter [\#667](https://github.com/auth0/auth0-flutter/pull/667) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- Updated the doc on hasValidCredentials [\#679](https://github.com/auth0/auth0-flutter/pull/679) ([pmathew92](https://github.com/pmathew92)) + ## [afpi-v1.14.0](https://github.com/auth0/auth0-flutter/tree/afpi-v1.14.0) (2025-09-24) [Full Changelog](https://github.com/auth0/auth0-flutter/compare/afpi-v1.13.0...afpi-v1.14.0) 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 3b4c5fd92..a65820a78 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -1,5 +1,6 @@ export 'src/account.dart'; export 'src/auth/api_exception.dart'; +export 'src/auth/auth_dpop_headers_options.dart'; export 'src/auth/auth_login_code_options.dart'; export 'src/auth/auth_login_options.dart'; export 'src/auth/auth_login_with_otp_options.dart'; @@ -12,8 +13,11 @@ export 'src/auth/auth_signup_options.dart'; export 'src/auth/auth_user_info_options.dart'; export 'src/auth/challenge.dart'; export 'src/auth/challenge_type.dart'; +export 'src/auth/dpop_headers.dart'; +export 'src/auth/empty_request_options.dart'; export 'src/auth0_exception.dart'; export 'src/auth0_flutter_auth_platform.dart'; +export 'src/auth0_flutter_dpop_platform.dart'; export 'src/auth0_flutter_web_auth_platform.dart'; export 'src/auth0_flutter_web_platform.dart'; export 'src/credentials-manager/credentials_manager_configuration.dart'; @@ -30,7 +34,9 @@ export 'src/database_user.dart'; export 'src/id_token_validation_config.dart'; 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/request/dpop_request.dart'; export 'src/request/request.dart'; export 'src/request/request_options.dart'; export 'src/user_agent.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_dpop_headers_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_dpop_headers_options.dart new file mode 100644 index 000000000..1b849faed --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_dpop_headers_options.dart @@ -0,0 +1,23 @@ +import '../request/request_options.dart'; + +class AuthDPoPHeadersOptions implements RequestOptions { + final String url; + final String method; + final String accessToken; + final String tokenType; + + const AuthDPoPHeadersOptions({ + required this.url, + required this.method, + required this.accessToken, + this.tokenType = 'Bearer', + }); + + @override + Map toMap() => { + 'url': url, + 'method': method, + 'accessToken': accessToken, + 'tokenType': tokenType, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_user_info_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_user_info_options.dart index dbfb405dd..506ea3e97 100644 --- a/auth0_flutter_platform_interface/lib/src/auth/auth_user_info_options.dart +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_user_info_options.dart @@ -2,13 +2,18 @@ import '../request/request_options.dart'; class AuthUserInfoOptions implements RequestOptions { final String accessToken; + final String tokenType; final Map parameters; - AuthUserInfoOptions({required this.accessToken, this.parameters = const {}}); + AuthUserInfoOptions( + {required this.accessToken, + this.tokenType = 'Bearer', + this.parameters = const {}}); @override Map toMap() => { 'accessToken': accessToken, + 'tokenType': tokenType, 'parameters': parameters, }; } diff --git a/auth0_flutter_platform_interface/lib/src/auth/dpop_headers.dart b/auth0_flutter_platform_interface/lib/src/auth/dpop_headers.dart new file mode 100644 index 000000000..26d58983c --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/dpop_headers.dart @@ -0,0 +1,14 @@ +class DPoPHeaders { + final String authorization; + final String dpop; + + const DPoPHeaders({ + required this.authorization, + required this.dpop, + }); + + factory DPoPHeaders.fromMap(final Map map) => DPoPHeaders( + authorization: map['authorization'] as String, + dpop: map['dpop'] as String, + ); +} diff --git a/auth0_flutter_platform_interface/lib/src/auth/empty_request_options.dart b/auth0_flutter_platform_interface/lib/src/auth/empty_request_options.dart new file mode 100644 index 000000000..2dee308fd --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/empty_request_options.dart @@ -0,0 +1,8 @@ +import '../request/request_options.dart'; + +class EmptyRequestOptions implements RequestOptions { + const EmptyRequestOptions(); + + @override + Map toMap() => {}; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart index 5318bf4a2..0d0c913e2 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart @@ -55,8 +55,8 @@ abstract class Auth0FlutterAuthPlatform extends PlatformInterface { Future startPasswordlessWithPhoneNumber( final ApiRequest request) { - throw UnimplementedError - ('startPasswordlessWithPhoneNumber() has not been implemented'); + throw UnimplementedError( + 'startPasswordlessWithPhoneNumber() has not been implemented'); } Future loginWithSmsCode( diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_dpop_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_dpop_platform.dart new file mode 100644 index 000000000..5fdde84ba --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_dpop_platform.dart @@ -0,0 +1,41 @@ +// coverage:ignore-file +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'auth/auth_dpop_headers_options.dart'; +import 'method_channel_auth0_flutter_dpop.dart'; +import 'request/dpop_request.dart'; +import 'request/request_options.dart'; + +/// Platform interface for DPoP (Demonstrating Proof-of-Possession) operations. +/// +/// DPoP methods are decoupled from authentication API methods as they are +/// utility operations for generating cryptographic proofs, not authentication +/// operations themselves. +abstract class Auth0FlutterDPoPPlatform extends PlatformInterface { + Auth0FlutterDPoPPlatform() : super(token: _token); + + static Auth0FlutterDPoPPlatform get instance => _instance; + static final Object _token = Object(); + static Auth0FlutterDPoPPlatform _instance = MethodChannelAuth0FlutterDPoP(); + + static set instance(final Auth0FlutterDPoPPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + /// Generates DPoP (Demonstrating Proof-of-Possession) headers for making + /// authenticated API requests. + /// + /// Returns a map containing the Authorization header and DPoP proof JWT. + Future> getDPoPHeaders( + final DPoPRequest request) { + throw UnimplementedError('getDPoPHeaders() has not been implemented'); + } + + /// Clears the DPoP private key from secure storage. + /// + /// Should be called on logout to remove the DPoP cryptographic key. + Future clearDPoPKey(final DPoPRequest request) { + throw UnimplementedError('clearDPoPKey() has not been implemented'); + } +} diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/options/local_authentication.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/options/local_authentication.dart index bf714f9b4..962c35078 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/options/local_authentication.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/options/local_authentication.dart @@ -1,3 +1,15 @@ +/// The level of authentication required. +enum LocalAuthenticationLevel { + /// Strong authentication (e.g. fingerprint, face scan, iris scan). + strong, + + /// Weak authentication (e.g. pattern, PIN, password). + weak, + + /// Device credential authentication (e.g. pattern, PIN, password). + deviceCredential +} + /// Settings for local authentication prompts. class LocalAuthentication { /// Title to display on the local authentication prompt. Defaults to **Please @@ -14,6 +26,14 @@ class LocalAuthentication { /// after a failed match. final String? fallbackTitle; + /// (Android only): The level of authentication required. Defaults to + /// [LocalAuthenticationLevel.strong]. + final LocalAuthenticationLevel? authenticationLevel; + const LocalAuthentication( - {this.title, this.description, this.cancelTitle, this.fallbackTitle}); + {this.title, + this.description, + this.cancelTitle, + this.fallbackTitle, + this.authenticationLevel}); } diff --git a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart index 652170dd0..38e335591 100644 --- a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart +++ b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart @@ -19,6 +19,7 @@ import 'request/request_options.dart'; import 'user_profile.dart'; const MethodChannel _channel = MethodChannel('auth0.com/auth0_flutter/auth'); + const String authLoginMethod = 'auth#login'; const String authLoginWithOtpMethod = 'auth#loginOtp'; const String authMultifactorChallengeMethod = 'auth#multifactorChallenge'; @@ -60,35 +61,36 @@ class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { return Challenge.fromMap(result); } - @override Future startPasswordlessWithEmail( final ApiRequest request) async { - await invokeRequest(method: authStartPasswordlessWithEmailMethod, - request: request,throwOnNull: false); + await invokeRequest( + method: authStartPasswordlessWithEmailMethod, + request: request, + throwOnNull: false); } - @override Future loginWithEmailCode( - final ApiRequest request) async{ - final Map result = await invokeRequest( - method: authLoginWithEmailCodeMethod, request: request); - return Credentials.fromMap(result); + final ApiRequest request) async { + final Map result = await invokeRequest( + method: authLoginWithEmailCodeMethod, request: request); + return Credentials.fromMap(result); } - @override Future startPasswordlessWithPhoneNumber( - final ApiRequest request) async{ - await invokeRequest(method: authStartPasswordlessWithPhoneNumberMethod, - request: request,throwOnNull: false); + final ApiRequest request) async { + await invokeRequest( + method: authStartPasswordlessWithPhoneNumberMethod, + request: request, + throwOnNull: false); } @override Future loginWithSmsCode( final ApiRequest request) async { - final Map result = await invokeRequest( + final Map result = await invokeRequest( method: authLoginWithSmsCodeMethod, request: request); return Credentials.fromMap(result); } @@ -125,10 +127,7 @@ class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { Future resetPassword( final ApiRequest request) async { await invokeRequest( - method: authResetPasswordMethod, - request: request, - throwOnNull: false, - ); + method: authResetPasswordMethod, request: request, throwOnNull: false); } Future> invokeRequest({ diff --git a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_dpop.dart b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_dpop.dart new file mode 100644 index 000000000..bbbd8ddf0 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_dpop.dart @@ -0,0 +1,51 @@ +// coverage:ignore-file +import 'package:flutter/services.dart'; + +import 'auth/api_exception.dart'; +import 'auth/auth_dpop_headers_options.dart'; +import 'auth0_flutter_dpop_platform.dart'; +import 'request/dpop_request.dart'; +import 'request/request_options.dart'; + +const String dpopGetHeadersMethod = 'dpop#getDPoPHeaders'; +const String dpopClearKeyMethod = 'dpop#clearDPoPKey'; + +/// Method channel implementation of [Auth0FlutterDPoPPlatform]. +class MethodChannelAuth0FlutterDPoP extends Auth0FlutterDPoPPlatform { + final MethodChannel _channel = + const MethodChannel('auth0.com/auth0_flutter/dpop'); + + @override + Future> getDPoPHeaders( + final DPoPRequest request) async { + final Map result = + await _invokeRequest(method: dpopGetHeadersMethod, request: request); + + return result.cast(); + } + + @override + Future clearDPoPKey(final DPoPRequest request) async { + await _invokeRequest( + method: dpopClearKeyMethod, request: request, throwOnNull: false); + } + + Future> _invokeRequest({ + required final String method, + required final DPoPRequest request, + final bool? throwOnNull = true, + }) async { + final Map? result; + try { + result = await _channel.invokeMapMethod(method, request.options.toMap()); + } on PlatformException catch (e) { + throw ApiException.fromPlatformException(e); + } + + if (result == null && throwOnNull == true) { + throw const ApiException.unknown('Channel returned null.'); + } + + return result ?? {}; + } +} diff --git a/auth0_flutter_platform_interface/lib/src/request/dpop_request.dart b/auth0_flutter_platform_interface/lib/src/request/dpop_request.dart new file mode 100644 index 000000000..9ca083c68 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/request/dpop_request.dart @@ -0,0 +1,13 @@ +import '../../auth0_flutter_platform_interface.dart'; +import 'request.dart'; + +/// Request object for DPoP utility operations. +/// +/// Unlike [ApiRequest], DPoP operations are utility functions that don't +/// require account information or user agent since they only perform local +/// cryptographic operations. +class DPoPRequest { + final TOptions options; + + const DPoPRequest({required this.options}); +} diff --git a/auth0_flutter_platform_interface/lib/src/request/request.dart b/auth0_flutter_platform_interface/lib/src/request/request.dart index 2004cd5a0..617bf1772 100644 --- a/auth0_flutter_platform_interface/lib/src/request/request.dart +++ b/auth0_flutter_platform_interface/lib/src/request/request.dart @@ -20,6 +20,7 @@ class CredentialsManagerRequest extends BaseRequest { final LocalAuthentication? localAuthentication; final CredentialsManagerConfiguration? credentialsManagerConfiguration; + final bool useDPoP; CredentialsManagerRequest({ required final Account account, @@ -27,11 +28,13 @@ class CredentialsManagerRequest required final UserAgent userAgent, this.localAuthentication, this.credentialsManagerConfiguration, + this.useDPoP = false, }) : super(account: account, options: options, userAgent: userAgent); @override Map toMap() { final map = super.toMap(); + map['useDPoP'] = useDPoP; if (localAuthentication != null) { map.addAll({ @@ -39,7 +42,8 @@ class CredentialsManagerRequest 'title': localAuthentication?.title, 'description': localAuthentication?.description, 'cancelTitle': localAuthentication?.cancelTitle, - 'fallbackTitle': localAuthentication?.fallbackTitle + 'fallbackTitle': localAuthentication?.fallbackTitle, + 'authenticationLevel': localAuthentication?.authenticationLevel?.index } }); } diff --git a/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_login_options.dart b/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_login_options.dart index 0bdecb34e..735f0df85 100644 --- a/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_login_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_login_options.dart @@ -7,21 +7,22 @@ class WebAuthLoginOptions extends LoginOptions { final String? scheme; final SafariViewController? safariViewController; final List allowedBrowsers; + final bool useDPoP; WebAuthLoginOptions( - { - super.audience, - super.idTokenValidationConfig, - super.organizationId, - super.invitationUrl, - super.redirectUrl, - super.scopes, - super.parameters, - this.useHTTPS = false, - this.useEphemeralSession = false, - this.scheme, - this.safariViewController, - this.allowedBrowsers = const []}); + {super.audience, + super.idTokenValidationConfig, + super.organizationId, + super.invitationUrl, + super.redirectUrl, + super.scopes, + super.parameters, + this.useHTTPS = false, + this.useEphemeralSession = false, + this.scheme, + this.safariViewController, + this.allowedBrowsers = const [], + this.useDPoP = false}); @override Map toMap() { @@ -31,6 +32,7 @@ class WebAuthLoginOptions extends LoginOptions { 'useHTTPS': useHTTPS, 'useEphemeralSession': useEphemeralSession, 'scheme': scheme, + 'useDPoP': useDPoP, ...safariViewController != null ? {'safariViewController': safariViewController?.toMap()} : {} diff --git a/auth0_flutter_platform_interface/lib/src/web/client_options.dart b/auth0_flutter_platform_interface/lib/src/web/client_options.dart index 77334b621..0a12282ed 100644 --- a/auth0_flutter_platform_interface/lib/src/web/client_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web/client_options.dart @@ -108,6 +108,9 @@ class ClientOptions { /// The default additional parameters to be sent to Auth0. final Map parameters; + /// Enables DPoP for token security. Defaults to `false`. + final bool useDPoP; + ClientOptions( {required this.account, this.authorizeTimeoutInSeconds, @@ -123,5 +126,6 @@ class ClientOptions { this.idTokenValidationConfig, this.audience, this.scopes, - this.parameters = const {}}); + this.parameters = const {}, + this.useDPoP = false}); } diff --git a/auth0_flutter_platform_interface/pubspec.yaml b/auth0_flutter_platform_interface/pubspec.yaml index 4b32dcefa..c99b551cf 100644 --- a/auth0_flutter_platform_interface/pubspec.yaml +++ b/auth0_flutter_platform_interface/pubspec.yaml @@ -1,6 +1,6 @@ name: auth0_flutter_platform_interface description: A common platform interface for the auth0_flutter federated plugin. -version: 1.14.0 +version: 2.0.0-beta.1 homepage: https://github.com/auth0/auth0-flutter diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart index 9f299f8ce..3b46f34c8 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart @@ -912,6 +912,35 @@ void main() { expect(verificationResult.arguments['parameters']['test'], 'test-123'); }); + test('defaults tokenType to Bearer when not specified', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => {'sub': 'test-id'}); + + await MethodChannelAuth0FlutterAuth().userInfo(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthUserInfoOptions(accessToken: 'test-token'))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['tokenType'], 'Bearer'); + }); + + test('correctly passes custom tokenType', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => {'sub': 'test-id'}); + + await MethodChannelAuth0FlutterAuth().userInfo(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthUserInfoOptions( + accessToken: 'test-token', tokenType: 'DPoP'))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['tokenType'], 'DPoP'); + }); + test('correctly returns the response from the Method Channel', () async { when(mocked.methodCallHandler(any)).thenAnswer((final _) async => { 'sub': 'test-id', diff --git a/auth0_flutter_platform_interface/test/web_auth_login_options_test.dart b/auth0_flutter_platform_interface/test/web_auth_login_options_test.dart index 1863b12e6..009a877ff 100644 --- a/auth0_flutter_platform_interface/test/web_auth_login_options_test.dart +++ b/auth0_flutter_platform_interface/test/web_auth_login_options_test.dart @@ -39,6 +39,35 @@ void main() { expect(map['scheme'], 'demo'); expect(map['allowedBrowsers'], ['chrome', 'firefox']); expect(map['safariViewController'], safariViewController.toMap()); + expect(map['useDPoP'], false); // Default value when not specified + }); + + test('toMap should include useDPoP when set to true', () { + final options = WebAuthLoginOptions( + useDPoP: true, + ); + + final map = options.toMap(); + + expect(map['useDPoP'], true); + }); + + test('toMap should include useDPoP when explicitly set to false', () { + final options = WebAuthLoginOptions( + + ); + + final map = options.toMap(); + + expect(map['useDPoP'], false); + }); + + test('toMap should default useDPoP to false when not specified', () { + final options = WebAuthLoginOptions(); + + final map = options.toMap(); + + expect(map['useDPoP'], false); }); test('toMap should handle null optional values gracefully', () { @@ -48,6 +77,7 @@ void main() { expect(map['useHTTPS'], false); expect(map['useEphemeralSession'], false); + expect(map['useDPoP'], false); expect(map['scheme'], isNull); expect(map['safariViewController'], isNull); expect(map['allowedBrowsers'], isEmpty); From 1380f56a863835d68b396c6564f778c4c8b1c661 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Fri, 19 Dec 2025 04:38:40 +0000 Subject: [PATCH 08/18] PKCE bug fixes --- auth0_flutter/example/lib/example_app.dart | 11 +++-- auth0_flutter/example/lib/main.dart | 2 +- auth0_flutter/example/windows/runner/main.cpp | 49 ++++++++++++++----- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/auth0_flutter/example/lib/example_app.dart b/auth0_flutter/example/lib/example_app.dart index a27f8d8f7..ac35ae401 100644 --- a/auth0_flutter/example/lib/example_app.dart +++ b/auth0_flutter/example/lib/example_app.dart @@ -18,7 +18,8 @@ class ExampleApp extends StatefulWidget { class _ExampleAppState extends State { bool _isLoggedIn = false; String _output = ''; - +String clientId = 'GGUVoHL5nseaacSzqB810HWYGHZI34m8'; +String domain = 'int-dx-enterprise-test.us.auth0.com'; late Auth0 auth0; late WebAuthentication webAuth; late Auth0Web auth0Web; @@ -27,11 +28,11 @@ class _ExampleAppState extends State { void initState() { super.initState(); - auth0 = Auth0(dotenv.env['AUTH0_DOMAIN']!, dotenv.env['AUTH0_CLIENT_ID']!); + auth0 = Auth0(domain, clientId); auth0Web = - Auth0Web(dotenv.env['AUTH0_DOMAIN']!, dotenv.env['AUTH0_CLIENT_ID']!); + Auth0Web(domain, clientId); webAuth = - auth0.webAuthentication(scheme: dotenv.env['AUTH0_CUSTOM_SCHEME']); + auth0.webAuthentication(scheme: 'https'); if (kIsWeb) { auth0Web.onLoad().then((final credentials) => setState(() { _output = credentials?.idToken ?? ''; @@ -159,7 +160,7 @@ class _ExampleAppState extends State { } else { // Mobile (Android/iOS): Use WebAuth with DPoP final webAuthDPoP = auth0.webAuthentication( - scheme: dotenv.env['AUTH0_CUSTOM_SCHEME'], + scheme: 'https', ); final result = await webAuthDPoP.login( diff --git a/auth0_flutter/example/lib/main.dart b/auth0_flutter/example/lib/main.dart index 0471bff42..c3dd15e56 100644 --- a/auth0_flutter/example/lib/main.dart +++ b/auth0_flutter/example/lib/main.dart @@ -6,6 +6,6 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'example_app.dart'; Future main() async { - await dotenv.load(); + // await dotenv.load(); runApp(const ExampleApp()); } diff --git a/auth0_flutter/example/windows/runner/main.cpp b/auth0_flutter/example/windows/runner/main.cpp index a971dd475..27f7f4971 100644 --- a/auth0_flutter/example/windows/runner/main.cpp +++ b/auth0_flutter/example/windows/runner/main.cpp @@ -65,37 +65,64 @@ void StartPipeServer() { }).detach(); } -int APIENTRY wWinMain(_In_ HINSTANCE instance, - _In_opt_ HINSTANCE prev, - _In_ wchar_t* command_line, - _In_ int show_command) { +int APIENTRY wWinMain( + _In_ HINSTANCE instance, + _In_opt_ HINSTANCE prev, + _In_ wchar_t* /*command_line*/, + _In_ int show_command) { + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + // ----------------------------- + // Parse command line properly + // ----------------------------- + int argc = 0; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + + std::wstring startupUri; + if (argv && argc > 1) { + // argv[1] is already de-quoted by Windows + startupUri = argv[1]; + } + + if (argv) { + LocalFree(argv); + } + + // ----------------------------- // Ensure single instance + // ----------------------------- HANDLE hMutex = CreateMutexW(NULL, TRUE, kSingleInstanceMutex); if (hMutex && GetLastError() == ERROR_ALREADY_EXISTS) { // Already running → forward URI (if present) and exit - if (command_line && wcslen(command_line) > 0) { - ForwardToFirstInstance(command_line); + if (!startupUri.empty()) { + ForwardToFirstInstance(startupUri.c_str()); } return 0; } - // First instance - if (command_line && wcslen(command_line) > 0) { - SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", command_line); + // ----------------------------- + // First instance: store startup URI + // ----------------------------- + if (!startupUri.empty()) { + SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", startupUri.c_str()); } else { SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", L""); } StartPipeServer(); + // ----------------------------- // Flutter bootstrap + // ----------------------------- flutter::DartProject project(L"data"); - std::vector command_line_arguments = GetCommandLineArguments(); + + std::vector command_line_arguments = + GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); @@ -114,4 +141,4 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, ::CoUninitialize(); return EXIT_SUCCESS; -} \ No newline at end of file +} From e6e936ebbb63a07d68540816e923f614d52f3c70 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Fri, 19 Dec 2025 04:41:11 +0000 Subject: [PATCH 09/18] code refactoring --- .../example/integration_test/plugin_integration_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth0_flutter/example/integration_test/plugin_integration_test.dart b/auth0_flutter/example/integration_test/plugin_integration_test.dart index 417f80523..6494be7c4 100644 --- a/auth0_flutter/example/integration_test/plugin_integration_test.dart +++ b/auth0_flutter/example/integration_test/plugin_integration_test.dart @@ -15,7 +15,7 @@ import 'package:auth0_flutter/auth0_flutter.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('getPlatformVersion test', (WidgetTester tester) async { + testWidgets('getPlatformVersion test', (final WidgetTester tester) async { final Auth0Flutter plugin = Auth0Flutter(); final String? version = await plugin.getPlatformVersion(); // The version string depends on the host platform running the test, so From ccf541b1ac2d754d1a7bf9b92f332c505073c637 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sun, 21 Dec 2025 10:13:32 +0000 Subject: [PATCH 10/18] fixes build issues --- auth0_flutter/example/windows/runner/main.cpp | 18 ++- .../lib/auth0_flutter_platform_interface.dart | 2 +- .../auth0_flutter_method_channel_test.dart | 6 +- auth0_flutter/test/auth0_flutter_test.dart | 4 +- auth0_flutter/windows/CMakeLists.txt | 13 +- auth0_flutter/windows/auth0_client.cpp | 59 ++++++++ auth0_flutter/windows/auth0_client.h | 18 +++ .../windows/auth0_flutter_plugin.cpp | 138 +++++------------- auth0_flutter/windows/auth0_models.h | 0 auth0_flutter/windows/credentials.h | 16 ++ auth0_flutter/windows/time_util.cpp | 30 ++++ auth0_flutter/windows/time_util.h | 6 + auth0_flutter/windows/token_decoder.cpp | 60 ++++++++ auth0_flutter/windows/token_decoder.h | 6 + 14 files changed, 257 insertions(+), 119 deletions(-) create mode 100644 auth0_flutter/windows/auth0_client.cpp create mode 100644 auth0_flutter/windows/auth0_client.h create mode 100644 auth0_flutter/windows/auth0_models.h create mode 100644 auth0_flutter/windows/credentials.h create mode 100644 auth0_flutter/windows/time_util.cpp create mode 100644 auth0_flutter/windows/time_util.h create mode 100644 auth0_flutter/windows/token_decoder.cpp create mode 100644 auth0_flutter/windows/token_decoder.h diff --git a/auth0_flutter/example/windows/runner/main.cpp b/auth0_flutter/example/windows/runner/main.cpp index 27f7f4971..41fb2fb62 100644 --- a/auth0_flutter/example/windows/runner/main.cpp +++ b/auth0_flutter/example/windows/runner/main.cpp @@ -96,14 +96,16 @@ int APIENTRY wWinMain( // ----------------------------- // Ensure single instance // ----------------------------- - HANDLE hMutex = CreateMutexW(NULL, TRUE, kSingleInstanceMutex); - if (hMutex && GetLastError() == ERROR_ALREADY_EXISTS) { - // Already running → forward URI (if present) and exit - if (!startupUri.empty()) { - ForwardToFirstInstance(startupUri.c_str()); - } - return 0; - } + bool hasUri = !startupUri.empty(); + +HANDLE hMutex = CreateMutexW(NULL, TRUE, kSingleInstanceMutex); +bool alreadyRunning = (hMutex && GetLastError() == ERROR_ALREADY_EXISTS); + +if (alreadyRunning && hasUri) { + // This is a protocol activation → forward and exit + ForwardToFirstInstance(startupUri.c_str()); + return 0; +} // ----------------------------- // First instance: store startup URI diff --git a/auth0_flutter/lib/auth0_flutter_platform_interface.dart b/auth0_flutter/lib/auth0_flutter_platform_interface.dart index 6b77be101..169ce5206 100644 --- a/auth0_flutter/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter/lib/auth0_flutter_platform_interface.dart @@ -18,7 +18,7 @@ abstract class Auth0FlutterPlatform extends PlatformInterface { /// Platform-specific implementations should set this with their own /// platform-specific class that extends [Auth0FlutterPlatform] when /// they register themselves. - static set instance(Auth0FlutterPlatform instance) { + static set instance(final Auth0FlutterPlatform instance) { PlatformInterface.verifyToken(instance, _token); _instance = instance; } diff --git a/auth0_flutter/test/auth0_flutter_method_channel_test.dart b/auth0_flutter/test/auth0_flutter_method_channel_test.dart index 20dca7076..328c7b1fd 100644 --- a/auth0_flutter/test/auth0_flutter_method_channel_test.dart +++ b/auth0_flutter/test/auth0_flutter_method_channel_test.dart @@ -5,15 +5,13 @@ import 'package:auth0_flutter/auth0_flutter_method_channel.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - MethodChannelAuth0Flutter platform = MethodChannelAuth0Flutter(); + final MethodChannelAuth0Flutter platform = MethodChannelAuth0Flutter(); const MethodChannel channel = MethodChannel('auth0_flutter'); setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( channel, - (MethodCall methodCall) async { - return '42'; - }, + (final MethodCall methodCall) async => '42', ); }); diff --git a/auth0_flutter/test/auth0_flutter_test.dart b/auth0_flutter/test/auth0_flutter_test.dart index 73332565b..0bcd8f9e8 100644 --- a/auth0_flutter/test/auth0_flutter_test.dart +++ b/auth0_flutter/test/auth0_flutter_test.dart @@ -20,8 +20,8 @@ void main() { }); test('getPlatformVersion', () async { - Auth0Flutter auth0FlutterPlugin = Auth0Flutter(); - MockAuth0FlutterPlatform fakePlatform = MockAuth0FlutterPlatform(); + final Auth0Flutter auth0FlutterPlugin = Auth0Flutter(); + final MockAuth0FlutterPlatform fakePlatform = MockAuth0FlutterPlatform(); Auth0FlutterPlatform.instance = fakePlatform; expect(await auth0FlutterPlugin.getPlatformVersion(), '42'); diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index ec9bea543..80dfc7fc9 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -2,7 +2,10 @@ # installed that includes CMake 3.14 or later. You should not increase this # version, as doing so will cause the plugin to fail to compile for some # customers of the plugin. -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.15) +cmake_policy(SET CMP0167 NEW) +project(auth0_flutter LANGUAGES CXX) + #if (DEFINED ENV{VCPKG_ROOT} AND EXISTS "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake") # set(CMAKE_TOOLCHAIN_FILE "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" # CACHE STRING "Vcpkg toolchain file") @@ -19,7 +22,6 @@ cmake_policy(VERSION 3.14...3.25) # This value is used when generating builds using this plugin, so it must # not be changed set(PLUGIN_NAME "auth0_flutter_plugin") - # Any new source files that you add to the plugin should be added here. list(APPEND PLUGIN_SOURCES "auth0_flutter_plugin.cpp" @@ -31,6 +33,8 @@ list(APPEND PLUGIN_SOURCES add_library(${PLUGIN_NAME} SHARED "include/auth0_flutter/auth0_flutter_plugin_c_api.h" "auth0_flutter_plugin_c_api.cpp" + "auth0_client.cpp" + "token_decoder.cpp" ${PLUGIN_SOURCES} ) @@ -45,7 +49,10 @@ apply_standard_settings(${PLUGIN_NAME}) set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) - +target_compile_definitions(${PLUGIN_NAME} + PRIVATE + _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING +) # Source include directories and library dependencies. target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") diff --git a/auth0_flutter/windows/auth0_client.cpp b/auth0_flutter/windows/auth0_client.cpp new file mode 100644 index 000000000..11d44f3f3 --- /dev/null +++ b/auth0_flutter/windows/auth0_client.cpp @@ -0,0 +1,59 @@ +#include "auth0_client.h" + +#include +#include + +#include "token_decoder.h" +using namespace web; +using namespace web::http; +using namespace web::http::client; + +static std::string GetJsonString( + const web::json::value& json, + const utility::string_t& key) { + if (json.has_field(key) && json.at(key).is_string()) { + return utility::conversions::to_utf8string(json.at(key).as_string()); + } + return {}; +} + +Auth0Client::Auth0Client(std::string domain, std::string clientId) + : domain_(std::move(domain)), + clientId_(std::move(clientId)) {} + +Credentials Auth0Client::ExchangeCodeForTokens( + const std::string& redirectUri, + const std::string& code, + const std::string& codeVerifier) { + + http_client client( + U("https://" + utility::conversions::to_string_t(domain_))); + + http_request request(methods::POST); + request.set_request_uri(U("/oauth/token")); + request.headers().set_content_type(U("application/json")); + + web::json::value body; + body[U("grant_type")] = web::json::value::string(U("authorization_code")); + body[U("client_id")] = + web::json::value::string(utility::conversions::to_string_t(clientId_)); + body[U("code")] = + web::json::value::string(utility::conversions::to_string_t(code)); + body[U("redirect_uri")] = + web::json::value::string(utility::conversions::to_string_t(redirectUri)); + body[U("code_verifier")] = + web::json::value::string(utility::conversions::to_string_t(codeVerifier)); + + request.set_body(body); + + auto response = client.request(request).get(); + auto json = response.extract_json().get(); + + if (response.status_code() != status_codes::OK) { + throw std::runtime_error( + "Token request failed: " + + GetJsonString(json, U("error_description"))); + } + + return DecodeTokenResponse(json); +} \ No newline at end of file diff --git a/auth0_flutter/windows/auth0_client.h b/auth0_flutter/windows/auth0_client.h new file mode 100644 index 000000000..6ee4d1f36 --- /dev/null +++ b/auth0_flutter/windows/auth0_client.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include "credentials.h" + +class Auth0Client { +public: + Auth0Client(std::string domain, std::string clientId); + + Credentials ExchangeCodeForTokens( + const std::string& redirectUri, + const std::string& code, + const std::string& codeVerifier); + +private: + std::string domain_; + std::string clientId_; +}; diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index c64107b08..98be76603 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -1,9 +1,7 @@ #define _CRT_SECURE_NO_WARNINGS #define _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING -#define _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING #define NOMINMAX #include "auth0_flutter_plugin.h" - // This must be included before many other Windows headers. #include @@ -31,15 +29,16 @@ #include #include +#include "auth0_client.h" +#include "time_util.h" +#include "credentials.h" + using namespace web; using namespace web::http; using namespace web::http::client; using namespace web::http::experimental::listener; namespace auth0_flutter { - void DebugPrint(const std::string& msg) { - OutputDebugStringA((msg + "\n").c_str()); -} // -------------------- PKCE Helpers -------------------- @@ -195,7 +194,6 @@ auto readAndClearEnv = []() -> std::string { return WideToUtf8(wstr); }; - while (elapsed < timeoutSeconds * 1000) { std::string uri = readAndClearEnv(); if (!uri.empty()) { @@ -263,89 +261,8 @@ std::string waitForAuthCode(const std::string& redirectUri) { } // -------------------- Token Exchange -------------------- -web::json::value exchangeCodeForTokens( - const std::string& domain, - const std::string& clientId, - const std::string& redirectUri, - const std::string& code, - const std::string& codeVerifier) { - - http_client client(U("https://" + utility::conversions::to_string_t(domain))); - - http_request request(methods::POST); - request.set_request_uri(U("/oauth/token")); - request.headers().set_content_type(U("application/json")); - - web::json::value body; - body[U("grant_type")] = web::json::value::string(U("authorization_code")); - body[U("client_id")] = web::json::value::string(utility::conversions::to_string_t(clientId)); - body[U("code")] = web::json::value::string(utility::conversions::to_string_t(code)); - body[U("redirect_uri")] = web::json::value::string(utility::conversions::to_string_t(redirectUri)); - body[U("code_verifier")] = web::json::value::string(utility::conversions::to_string_t(codeVerifier)); - DebugPrint("codeVerifier = " + codeVerifier); - DebugPrint("redirect_uri = " + redirectUri); - request.set_body(body); -DebugPrint("➡️ POST https://" + domain + "/oauth/token"); -DebugPrint("Request body: " + utility::conversions::to_utf8string(body.serialize())); - auto response = client.request(request).get(); - - // ---- Debug: status & headers ---- - DebugPrint("HTTP Status: " + std::to_string(response.status_code())); - for (const auto& h : response.headers()) { - DebugPrint("Header: " + utility::conversions::to_utf8string(h.first) + - " = " + utility::conversions::to_utf8string(h.second)); - } - // ---- Read response body as string ---- - auto bodyStr = response.extract_string().get(); - DebugPrint("Response Body: " + utility::conversions::to_utf8string(bodyStr)); - if (response.status_code() != status_codes::OK) { - throw std::runtime_error("Token request failed: " + utility::conversions::to_utf8string(bodyStr)); - } - - // ---- Parse JSON if successful ---- - return web::json::value::parse(bodyStr); -} - -// web::json::value exchangeCodeForTokens( -// const std::string& domain, -// const std::string& clientId, -// const std::string& redirectUri, -// const std::string& code, -// const std::string& codeVerifier) { -// DebugPrint("domain=" + domain); -// DebugPrint("clientId=" + clientId); -// DebugPrint("redirectUri=" + redirectUri); -// DebugPrint("code=" + code); -// DebugPrint("codeVerifier=" + codeVerifier); -// http_client client( -// U("https://" + utility::conversions::to_string_t(domain))); - -// http_request request(methods::POST); -// request.set_request_uri(U("/oauth/token")); -// request.headers().set_content_type(U("application/json")); - -// web::json::value body; -// body[U("grant_type")] = web::json::value::string(U("authorization_code")); -// body[U("client_id")] = -// web::json::value::string(utility::conversions::to_string_t(clientId)); -// body[U("code")] = -// web::json::value::string(utility::conversions::to_string_t(code)); -// body[U("redirect_uri")] = -// web::json::value::string(utility::conversions::to_string_t(redirectUri)); -// body[U("code_verifier")] = -// web::json::value::string(utility::conversions::to_string_t(codeVerifier)); - -// request.set_body(body); - -// auto response = client.request(request).get(); -// if (response.status_code() != status_codes::OK) { -// throw std::runtime_error("Token request failed"); -// } - -// return response.extract_json().get(); -// } // -------------------- Plugin Impl -------------------- @@ -369,7 +286,9 @@ void Auth0FlutterPlugin::RegisterWithRegistrar( Auth0FlutterPlugin::Auth0FlutterPlugin() {} Auth0FlutterPlugin::~Auth0FlutterPlugin() {} - +void DebugPrint(const std::string& msg) { + OutputDebugStringA((msg + "\n").c_str()); +} void Auth0FlutterPlugin::HandleMethodCall( const flutter::MethodCall &method_call, @@ -410,8 +329,6 @@ void Auth0FlutterPlugin::HandleMethodCall( } std::string redirectUri = "auth0flutter://callback"; -// authUrl = https://int-dx-enterprise-test.us.auth0.com/authorize?response_type=code&client_id=GGUVoHL5nseaacSzqB810HWYGHZI34m8&redirect_uri=auth0flutter://callback&scope=openid%20profile%20email&code_challenge=JnkpdGGqlvYT_BiinnxwrVK6ocB1PtYEERW4Akttaw0&code_challenge_method=S256 - try { // 1. PKCE @@ -428,7 +345,6 @@ void Auth0FlutterPlugin::HandleMethodCall( << "&scope=openid%20profile%20email" << "&code_challenge=" << codeChallenge << "&code_challenge_method=S256"; - DebugPrint("authUrl = " + authUrl.str()); // 3. Open browser ShellExecuteA(NULL, "open", authUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); @@ -437,17 +353,37 @@ void Auth0FlutterPlugin::HandleMethodCall( std::string code = waitForAuthCode_CustomScheme(redirectUri, 180); // 5. Exchange code for tokens - auto tokens = - exchangeCodeForTokens(domain, clientId, redirectUri, code, codeVerifier); + Auth0Client client(domain, clientId); + Credentials creds = client.ExchangeCodeForTokens(redirectUri, code, codeVerifier); + flutter::EncodableMap response; + + response[flutter::EncodableValue("accessToken")] = + flutter::EncodableValue(creds.accessToken); + +response[flutter::EncodableValue("idToken")] = + flutter::EncodableValue(creds.idToken); + +response[flutter::EncodableValue("refreshToken")] = + flutter::EncodableValue(creds.refreshToken); + +response[flutter::EncodableValue("tokenType")] = + flutter::EncodableValue(creds.tokenType); + - result->Success(flutter::EncodableValue( - utility::conversions::to_utf8string(tokens.serialize()))); +// if (creds.expiresAt.has_value()) { +// response[flutter::EncodableValue("expiresAt")] = +// flutter::EncodableValue(ToIso8601(creds.expiresAt.value())); +// } + +// response[flutter::EncodableValue("scopes")] = +// flutter::EncodableValue(creds.scopes); + result->Success(flutter::EncodableValue(response)); } catch (const std::exception& e) { - result->Error("auth_failed", e.what()); + result->Error("auth_failed", e.what()); + } + } else { + result->NotImplemented(); + } } - } else { - result->NotImplemented(); - } -} -} // namespace auth0_flutter +} // namespace auth0_flutter \ No newline at end of file diff --git a/auth0_flutter/windows/auth0_models.h b/auth0_flutter/windows/auth0_models.h new file mode 100644 index 000000000..e69de29bb diff --git a/auth0_flutter/windows/credentials.h b/auth0_flutter/windows/credentials.h new file mode 100644 index 000000000..fbfd2eb2d --- /dev/null +++ b/auth0_flutter/windows/credentials.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include +#include +#include + +struct Credentials { + std::string accessToken; + std::string idToken; + std::string refreshToken; + std::string tokenType; + std::vector scopes; + + std::optional expiresIn; // seconds + std::optional expiresAt; +}; diff --git a/auth0_flutter/windows/time_util.cpp b/auth0_flutter/windows/time_util.cpp new file mode 100644 index 000000000..dc8fbb8f6 --- /dev/null +++ b/auth0_flutter/windows/time_util.cpp @@ -0,0 +1,30 @@ +#include "time_util.h" +#include +#include + +std::string ToIso8601( + const std::chrono::system_clock::time_point& tp) { + std::time_t t = std::chrono::system_clock::to_time_t(tp); + std::tm utc{}; + gmtime_s(&utc, &t); + + std::ostringstream oss; + oss << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} + +static std::string ToIso8601( + const std::chrono::system_clock::time_point& tp) { + std::time_t t = std::chrono::system_clock::to_time_t(tp); + + std::tm utc_tm{}; +#if defined(_WIN32) + gmtime_s(&utc_tm, &t); +#else + gmtime_r(&t, &utc_tm); +#endif + + std::ostringstream oss; + oss << std::put_time(&utc_tm, "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} \ No newline at end of file diff --git a/auth0_flutter/windows/time_util.h b/auth0_flutter/windows/time_util.h new file mode 100644 index 000000000..af42364a2 --- /dev/null +++ b/auth0_flutter/windows/time_util.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include + +std::string ToIso8601( + const std::chrono::system_clock::time_point& tp); diff --git a/auth0_flutter/windows/token_decoder.cpp b/auth0_flutter/windows/token_decoder.cpp new file mode 100644 index 000000000..6ae3eb199 --- /dev/null +++ b/auth0_flutter/windows/token_decoder.cpp @@ -0,0 +1,60 @@ +#include "token_decoder.h" +#include + +Credentials DecodeTokenResponse( + const web::json::value& json) { + + Credentials creds; + + // ---- Required fields ---- + creds.accessToken = + utility::conversions::to_utf8string( + json.at(U("access_token")).as_string()); + + creds.tokenType = + utility::conversions::to_utf8string( + json.at(U("token_type")).as_string()); + + // ---- Optional fields ---- + if (json.has_field(U("id_token"))) { + creds.idToken = + utility::conversions::to_utf8string( + json.at(U("id_token")).as_string()); + } + + if (json.has_field(U("refresh_token"))) { + creds.refreshToken = + utility::conversions::to_utf8string( + json.at(U("refresh_token")).as_string()); + } + + // ---- Scopes (space-separated) ---- + if (json.has_field(U("scope"))) { + auto scopeStr = + utility::conversions::to_utf8string( + json.at(U("scope")).as_string()); + + size_t pos = 0; + while ((pos = scopeStr.find(' ')) != std::string::npos) { + creds.scopes.push_back(scopeStr.substr(0, pos)); + scopeStr.erase(0, pos + 1); + } + if (!scopeStr.empty()) { + creds.scopes.push_back(scopeStr); + } + } + + // ---- expires_in → expiresAt (Kotlin-equivalent logic) ---- + if (json.has_field(U("expires_in"))) { + long long expiresIn = + json.at(U("expires_in")).as_integer(); + + creds.expiresIn = expiresIn; + + auto now = std::chrono::system_clock::now(); + creds.expiresAt = + now + std::chrono::seconds(expiresIn); + } + + return creds; +} diff --git a/auth0_flutter/windows/token_decoder.h b/auth0_flutter/windows/token_decoder.h new file mode 100644 index 000000000..20ae82c0d --- /dev/null +++ b/auth0_flutter/windows/token_decoder.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include "credentials.h" + +Credentials DecodeTokenResponse( + const web::json::value& json); From 46f28b52ab11d7f8c01bfb3574be286b242ccfd4 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sun, 21 Dec 2025 18:20:57 +0000 Subject: [PATCH 11/18] Fixes issue due to expires in --- auth0_flutter/windows/CMakeLists.txt | 1 + .../windows/auth0_flutter_plugin.cpp | 47 ++++++++++++---- auth0_flutter/windows/time_util.cpp | 48 +++++++++++------ auth0_flutter/windows/time_util.h | 9 +++- auth0_flutter/windows/token_decoder.cpp | 54 +++++++++++-------- 5 files changed, 109 insertions(+), 50 deletions(-) diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index 80dfc7fc9..b0cc15e77 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -35,6 +35,7 @@ add_library(${PLUGIN_NAME} SHARED "auth0_flutter_plugin_c_api.cpp" "auth0_client.cpp" "token_decoder.cpp" + "time_util.cpp" ${PLUGIN_SOURCES} ) diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index 98be76603..2c55f5315 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -327,7 +327,33 @@ void Auth0FlutterPlugin::HandleMethodCall( it != accountMap->end()) { domain = std::get(it->second); } - + +std::string scopeStr = "openid profile email"; // default + +auto scopesIt = args->find(flutter::EncodableValue("scopes")); +if (scopesIt != args->end()) { + const auto* scopeList = + std::get_if(&scopesIt->second); + if (!scopeList) { + result->Error("bad_args", "'scopes' must be a List"); + return; + } + + std::ostringstream oss; + bool first = true; + for (const auto& v : *scopeList) { + const auto* s = std::get_if(&v); + if (!s) { + result->Error("bad_args", "Each scope must be a String"); + return; + } + if (!first) oss << " "; + oss << *s; + first = false; + } + + scopeStr = oss.str(); +} std::string redirectUri = "auth0flutter://callback"; try { @@ -342,7 +368,7 @@ void Auth0FlutterPlugin::HandleMethodCall( << "response_type=code" << "&client_id=" << clientId << "&redirect_uri=" << redirectUri - << "&scope=openid%20profile%20email" + << "&scope=" << scopeStr << "&code_challenge=" << codeChallenge << "&code_challenge_method=S256"; @@ -369,14 +395,17 @@ response[flutter::EncodableValue("refreshToken")] = response[flutter::EncodableValue("tokenType")] = flutter::EncodableValue(creds.tokenType); +if (creds.expiresAt.has_value()) { + response[flutter::EncodableValue("expiresAt")] = + flutter::EncodableValue(ToIso8601(creds.expiresAt.value())); +} + flutter::EncodableList scopes; + for (const auto& credscope : creds.scopes) { + scopes.emplace_back(credscope); // scope must be std::string + } -// if (creds.expiresAt.has_value()) { -// response[flutter::EncodableValue("expiresAt")] = -// flutter::EncodableValue(ToIso8601(creds.expiresAt.value())); -// } - -// response[flutter::EncodableValue("scopes")] = -// flutter::EncodableValue(creds.scopes); + response[flutter::EncodableValue("scopes")] = + flutter::EncodableValue(scopes); result->Success(flutter::EncodableValue(response)); } catch (const std::exception& e) { result->Error("auth_failed", e.what()); diff --git a/auth0_flutter/windows/time_util.cpp b/auth0_flutter/windows/time_util.cpp index dc8fbb8f6..0ed582e24 100644 --- a/auth0_flutter/windows/time_util.cpp +++ b/auth0_flutter/windows/time_util.cpp @@ -1,30 +1,44 @@ #include "time_util.h" + #include #include +#include -std::string ToIso8601( - const std::chrono::system_clock::time_point& tp) { - std::time_t t = std::chrono::system_clock::to_time_t(tp); - std::tm utc{}; - gmtime_s(&utc, &t); +std::optional +ParseIso8601(const std::string& iso) { + if (iso.empty()) { + return std::nullopt; + } + + std::tm tm{}; + std::istringstream ss(iso); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); - std::ostringstream oss; - oss << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); - return oss.str(); + if (ss.fail()) { + return std::nullopt; + } + +#if defined(_WIN32) + std::time_t t = _mkgmtime(&tm); // Windows UTC +#else + std::time_t t = timegm(&tm); // POSIX UTC +#endif + + return std::chrono::system_clock::from_time_t(t); } -static std::string ToIso8601( - const std::chrono::system_clock::time_point& tp) { +std::string +ToIso8601(const std::chrono::system_clock::time_point& tp) { std::time_t t = std::chrono::system_clock::to_time_t(tp); + std::tm tm{}; - std::tm utc_tm{}; #if defined(_WIN32) - gmtime_s(&utc_tm, &t); + gmtime_s(&tm, &t); #else - gmtime_r(&t, &utc_tm); + gmtime_r(&t, &tm); #endif - std::ostringstream oss; - oss << std::put_time(&utc_tm, "%Y-%m-%dT%H:%M:%SZ"); - return oss.str(); -} \ No newline at end of file + std::ostringstream ss; + ss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); + return ss.str(); +} diff --git a/auth0_flutter/windows/time_util.h b/auth0_flutter/windows/time_util.h index af42364a2..fbc08eca2 100644 --- a/auth0_flutter/windows/time_util.h +++ b/auth0_flutter/windows/time_util.h @@ -1,6 +1,11 @@ #pragma once + #include +#include #include -std::string ToIso8601( - const std::chrono::system_clock::time_point& tp); +std::optional +ParseIso8601(const std::string& iso); + +std::string +ToIso8601(const std::chrono::system_clock::time_point& tp); diff --git a/auth0_flutter/windows/token_decoder.cpp b/auth0_flutter/windows/token_decoder.cpp index 6ae3eb199..6872636e3 100644 --- a/auth0_flutter/windows/token_decoder.cpp +++ b/auth0_flutter/windows/token_decoder.cpp @@ -1,6 +1,6 @@ #include "token_decoder.h" #include - +#include "time_util.h" Credentials DecodeTokenResponse( const web::json::value& json) { @@ -28,32 +28,42 @@ Credentials DecodeTokenResponse( json.at(U("refresh_token")).as_string()); } - // ---- Scopes (space-separated) ---- - if (json.has_field(U("scope"))) { - auto scopeStr = - utility::conversions::to_utf8string( - json.at(U("scope")).as_string()); - - size_t pos = 0; - while ((pos = scopeStr.find(' ')) != std::string::npos) { - creds.scopes.push_back(scopeStr.substr(0, pos)); - scopeStr.erase(0, pos + 1); - } - if (!scopeStr.empty()) { - creds.scopes.push_back(scopeStr); - } + if (json.has_field(U("expires_in")) && + json.at(U("expires_in")).is_integer()) { + creds.expiresIn = json.at(U("expires_in")).as_integer(); } + + // Try expires_at from JSON + if (json.has_field(U("expires_at")) && + json.at(U("expires_at")).is_string()) { - // ---- expires_in → expiresAt (Kotlin-equivalent logic) ---- - if (json.has_field(U("expires_in"))) { - long long expiresIn = - json.at(U("expires_in")).as_integer(); + auto iso = utility::conversions::to_utf8string( + json.at(U("expires_at")).as_string()); - creds.expiresIn = expiresIn; + creds.expiresAt = ParseIso8601(iso); + } - auto now = std::chrono::system_clock::now(); + // If expires_at missing, compute from expires_in + if (!creds.expiresAt.has_value() && creds.expiresIn.has_value()) { creds.expiresAt = - now + std::chrono::seconds(expiresIn); + std::chrono::system_clock::now() + + std::chrono::seconds(creds.expiresIn.value()); + } + + // -------------------------------------------------- + + // scope (optional, space-separated string) + if (json.has_field(U("scope")) && + json.at(U("scope")).is_string()) { + + auto scopeStr = utility::conversions::to_utf8string( + json.at(U("scope")).as_string()); + + std::istringstream iss(scopeStr); + std::string s; + while (iss >> s) { + creds.scopes.push_back(s); + } } return creds; From 95310b6b8d38b3f2cfe150fbce917f04e079cd8c Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Sat, 3 Jan 2026 11:54:31 +0000 Subject: [PATCH 12/18] Fixes PKCE issues --- auth0_flutter/windows/CMakeLists.txt | 4 + .../windows/auth0_flutter_plugin.cpp | 19 ++- auth0_flutter/windows/credentials.cpp | 48 +++++++ auth0_flutter/windows/credentials.h | 22 +++- auth0_flutter/windows/jwt_util.cpp | 98 +++++++++++++++ auth0_flutter/windows/jwt_util.h | 18 +++ auth0_flutter/windows/token_decoder.cpp | 2 +- auth0_flutter/windows/user_identity.cpp | 74 +++++++++++ auth0_flutter/windows/user_identity.h | 22 ++++ auth0_flutter/windows/user_profile.cpp | 119 ++++++++++++++++++ auth0_flutter/windows/user_profile.h | 28 +++++ 11 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 auth0_flutter/windows/credentials.cpp create mode 100644 auth0_flutter/windows/jwt_util.cpp create mode 100644 auth0_flutter/windows/jwt_util.h create mode 100644 auth0_flutter/windows/user_identity.cpp create mode 100644 auth0_flutter/windows/user_identity.h create mode 100644 auth0_flutter/windows/user_profile.cpp create mode 100644 auth0_flutter/windows/user_profile.h diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index b0cc15e77..43d395550 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -36,6 +36,10 @@ add_library(${PLUGIN_NAME} SHARED "auth0_client.cpp" "token_decoder.cpp" "time_util.cpp" + "user_profile.cpp" + "user_identity.cpp" + "credentials.cpp" + "jwt_util.cpp" ${PLUGIN_SOURCES} ) diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index 2c55f5315..8337fce41 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -32,6 +32,9 @@ #include "auth0_client.h" #include "time_util.h" #include "credentials.h" +#include "user_identity.h" +#include "user_profile.h" +#include "jwt_util.h" using namespace web; using namespace web::http; @@ -389,8 +392,10 @@ if (scopesIt != args->end()) { response[flutter::EncodableValue("idToken")] = flutter::EncodableValue(creds.idToken); + if (creds.refreshToken.has_value()) { response[flutter::EncodableValue("refreshToken")] = - flutter::EncodableValue(creds.refreshToken); + flutter::EncodableValue(creds.refreshToken.value()); + } response[flutter::EncodableValue("tokenType")] = flutter::EncodableValue(creds.tokenType); @@ -400,12 +405,18 @@ if (creds.expiresAt.has_value()) { flutter::EncodableValue(ToIso8601(creds.expiresAt.value())); } flutter::EncodableList scopes; - for (const auto& credscope : creds.scopes) { + for (const auto& credscope : creds.scope) { scopes.emplace_back(credscope); // scope must be std::string } - response[flutter::EncodableValue("scopes")] = - flutter::EncodableValue(scopes); + response[flutter::EncodableValue("scopes")] = flutter::EncodableValue(scopes); + + web::json::value payload_json = DecodeJwtPayload(creds.idToken); + auto ev = JsonToEncodable(payload_json); + auto payload_map = std::get(ev); + UserProfile user = UserProfile::DeserializeUserProfile(payload_map); + response[flutter::EncodableValue("userProfile")] = flutter::EncodableValue(user.ToMap()); + result->Success(flutter::EncodableValue(response)); } catch (const std::exception& e) { result->Error("auth_failed", e.what()); diff --git a/auth0_flutter/windows/credentials.cpp b/auth0_flutter/windows/credentials.cpp new file mode 100644 index 000000000..5eabe68a9 --- /dev/null +++ b/auth0_flutter/windows/credentials.cpp @@ -0,0 +1,48 @@ +#include "credentials.h" + +#include "jwt_util.h" +#include "time_util.h" + +// UserProfile Credentials::GetUser() const { + +// auto payloadJson = DecodeJwtPayload(idToken); +// return UserProfile::FromJwtPayload(payloadJson); +// } + +// flutter::EncodableMap Credentials::ToEncodableMap() const { +// flutter::EncodableMap map; + +// map[flutter::EncodableValue("accessToken")] = flutter::EncodableValue(accessToken); +// map[flutter::EncodableValue("idToken")] = flutter::EncodableValue(idToken); +// map[flutter::EncodableValue("tokenType")] = flutter::EncodableValue(tokenType); + +// if (refreshToken.has_value()) { +// map[flutter::EncodableValue("refreshToken")] = flutter::EncodableValue(*refreshToken); +// } + +// // expiresIn (seconds) +// if (expiresIn.has_value()) { +// map[flutter::EncodableValue("expiresIn")] = +// flutter::EncodableValue(static_cast(*expiresIn)); +// } + +// // expiresAt (ISO-8601 string, same as Android) +// if (expiresAt.has_value()) { +// map[flutter::EncodableValue("expiresAt")] = +// flutter::EncodableValue(ToIso8601(*expiresAt)); +// } + +// // scope list +// if (!scope.empty()) { +// flutter::EncodableList scopes; +// for (const auto& s : scope) { +// scopes.emplace_back(s); +// } +// map[flutter::EncodableValue("scope")] = flutter::EncodableValue(scopes); +// } + +// // ✅ Computed user property +// map[flutter::EncodableValue("userProfile")] = flutter::EncodableValue(GetUser().ToEncodableMap()); + +// return map; +// } \ No newline at end of file diff --git a/auth0_flutter/windows/credentials.h b/auth0_flutter/windows/credentials.h index fbfd2eb2d..6e3abb4ee 100644 --- a/auth0_flutter/windows/credentials.h +++ b/auth0_flutter/windows/credentials.h @@ -1,16 +1,30 @@ #pragma once + #include #include #include #include -struct Credentials { +#include + +#include "user_profile.h" + +class Credentials { + public: + // ===== Raw credential fields ===== std::string accessToken; std::string idToken; - std::string refreshToken; std::string tokenType; - std::vector scopes; - std::optional expiresIn; // seconds + std::optional refreshToken; + std::optional expiresIn; // seconds std::optional expiresAt; + + std::vector scope; + + // // ===== Computed properties ===== + // UserProfile GetUser() const; + + // // ===== Serialization ===== + // flutter::EncodableMap ToEncodableMap() const; }; diff --git a/auth0_flutter/windows/jwt_util.cpp b/auth0_flutter/windows/jwt_util.cpp new file mode 100644 index 000000000..cff26dd3f --- /dev/null +++ b/auth0_flutter/windows/jwt_util.cpp @@ -0,0 +1,98 @@ +#include "jwt_util.h" + +#include +#include +#include +#include +#include + +#pragma comment(lib, "Crypt32.lib") + +static std::string Base64UrlDecode(const std::string& input) { + std::string padded = input; + std::replace(padded.begin(), padded.end(), '-', '+'); + std::replace(padded.begin(), padded.end(), '_', '/'); + while (padded.size() % 4 != 0) padded.push_back('='); + + DWORD out_len = 0; + CryptStringToBinaryA( + padded.c_str(), + static_cast(padded.size()), + CRYPT_STRING_BASE64, + nullptr, + &out_len, + nullptr, + nullptr); + + std::string output(out_len, '\0'); + CryptStringToBinaryA( + padded.c_str(), + static_cast(padded.size()), + CRYPT_STRING_BASE64, + reinterpret_cast(&output[0]), + &out_len, + nullptr, + nullptr); + + return output; +} + +JwtParts SplitJwt(const std::string& token) { + std::stringstream ss(token); + std::string part; + std::vector parts; + + while (std::getline(ss, part, '.')) { + parts.push_back(part); + } + + if (parts.size() == 2 && !token.empty() && token.back() == '.') { + parts.push_back(""); + } + + if (parts.size() != 3) { + throw std::runtime_error("JWT must have exactly 3 parts"); + } + + return {parts[0], parts[1], parts[2]}; +} + +web::json::value DecodeJwtPayload(const std::string& token) { + auto parts = SplitJwt(token); + auto decoded = Base64UrlDecode(parts.payload); + return web::json::value::parse(decoded); +} + +flutter::EncodableValue JsonToEncodable(const web::json::value& v) { + if (v.is_null()) return flutter::EncodableValue(); + + if (v.is_boolean()) return flutter::EncodableValue(v.as_bool()); + if (v.is_number()) return flutter::EncodableValue(v.as_double()); + if (v.is_string()) + return flutter::EncodableValue(utility::conversions::to_utf8string(v.as_string())); + + if (v.is_array()) { + flutter::EncodableList list; + for (const auto& item : v.as_array()) { + list.push_back(JsonToEncodable(item)); + } + return flutter::EncodableValue(list); + } + + if (v.is_object()) { + flutter::EncodableMap map; + for (const auto& kv : v.as_object()) { + map[flutter::EncodableValue(utility::conversions::to_utf8string(kv.first))] = + JsonToEncodable(kv.second); + } + return flutter::EncodableValue(map); + } + + return flutter::EncodableValue(); +} + +flutter::EncodableMap ParseJsonToEncodableMap(const std::string& json) { + auto parsed = web::json::value::parse(json); + auto ev = JsonToEncodable(parsed); + return std::get(ev); +} \ No newline at end of file diff --git a/auth0_flutter/windows/jwt_util.h b/auth0_flutter/windows/jwt_util.h new file mode 100644 index 000000000..774d2755b --- /dev/null +++ b/auth0_flutter/windows/jwt_util.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +struct JwtParts { + std::string header; + std::string payload; + std::string signature; +}; + +JwtParts SplitJwt(const std::string& token); +web::json::value DecodeJwtPayload(const std::string& token); + +// SAFE conversion +flutter::EncodableMap ParseJsonToEncodableMap(const std::string& json); +flutter::EncodableValue JsonToEncodable(const web::json::value& v); \ No newline at end of file diff --git a/auth0_flutter/windows/token_decoder.cpp b/auth0_flutter/windows/token_decoder.cpp index 6872636e3..6852a17ab 100644 --- a/auth0_flutter/windows/token_decoder.cpp +++ b/auth0_flutter/windows/token_decoder.cpp @@ -62,7 +62,7 @@ Credentials DecodeTokenResponse( std::istringstream iss(scopeStr); std::string s; while (iss >> s) { - creds.scopes.push_back(s); + creds.scope.push_back(s); } } diff --git a/auth0_flutter/windows/user_identity.cpp b/auth0_flutter/windows/user_identity.cpp new file mode 100644 index 000000000..deb2dcc5d --- /dev/null +++ b/auth0_flutter/windows/user_identity.cpp @@ -0,0 +1,74 @@ +#include "user_identity.h" +#include "jwt_util.h" + +using web::json::value; + +static std::string GetRequiredString( + const value& v, const utility::string_t& key) { + return utility::conversions::to_utf8string(v.at(key).as_string()); +} + +static std::optional GetOptionalString( + const value& v, const utility::string_t& key) { + if (v.has_field(key) && v.at(key).is_string()) { + return utility::conversions::to_utf8string(v.at(key).as_string()); + } + return std::nullopt; +} + +UserIdentity UserIdentity::FromJson(const value& json) { + UserIdentity identity; + + identity.id = GetRequiredString(json, U("user_id")); + identity.connection = GetRequiredString(json, U("connection")); + identity.provider = GetRequiredString(json, U("provider")); + + if (json.has_field(U("isSocial"))) { + identity.isSocial = json.at(U("isSocial")).as_bool(); + } + + identity.accessToken = GetOptionalString(json, U("access_token")); + identity.accessTokenSecret = GetOptionalString(json, U("access_token_secret")); + + if (json.has_field(U("profileData")) && + json.at(U("profileData")).is_object()) { + for (const auto& kv : json.at(U("profileData")).as_object()) { + identity.profileInfo[flutter::EncodableValue( + utility::conversions::to_utf8string(kv.first))] = + JsonToEncodable(kv.second); + } + } + + return identity; +} + +UserIdentity UserIdentity::FromEncodable(const flutter::EncodableMap& map) { + UserIdentity id; + + auto it = map.find(flutter::EncodableValue("provider")); + if (it != map.end() && std::holds_alternative(it->second)) { + id.provider = std::get(it->second); + } + + it = map.find(flutter::EncodableValue("user_id")); + if (it != map.end() && std::holds_alternative(it->second)) { + id.id = std::get(it->second); + } + + return id; +} + +flutter::EncodableMap UserIdentity::ToEncodableMap() const { + flutter::EncodableMap map; + + map[flutter::EncodableValue("id")] = flutter::EncodableValue(id); + map[flutter::EncodableValue("connection")] = flutter::EncodableValue(connection); + map[flutter::EncodableValue("provider")] = flutter::EncodableValue(provider); + map[flutter::EncodableValue("isSocial")] = flutter::EncodableValue(isSocial); + + if (accessToken) map[flutter::EncodableValue("accessToken")] = flutter::EncodableValue(*accessToken); + if (accessTokenSecret) map[flutter::EncodableValue("accessTokenSecret")] = flutter::EncodableValue(*accessTokenSecret); + if (!profileInfo.empty()) map[flutter::EncodableValue("profileInfo")] = flutter::EncodableValue(profileInfo); + + return map; +} \ No newline at end of file diff --git a/auth0_flutter/windows/user_identity.h b/auth0_flutter/windows/user_identity.h new file mode 100644 index 000000000..61083fd4b --- /dev/null +++ b/auth0_flutter/windows/user_identity.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include +#include + +class UserIdentity { + public: + std::string id; + std::string connection; + std::string provider; + bool isSocial = false; + std::optional accessToken; + std::optional accessTokenSecret; + flutter::EncodableMap profileInfo; + + static UserIdentity FromJson(const web::json::value& json); + static UserIdentity FromEncodable(const flutter::EncodableMap& map); + + flutter::EncodableMap ToEncodableMap() const; +}; \ No newline at end of file diff --git a/auth0_flutter/windows/user_profile.cpp b/auth0_flutter/windows/user_profile.cpp new file mode 100644 index 000000000..136dd1c18 --- /dev/null +++ b/auth0_flutter/windows/user_profile.cpp @@ -0,0 +1,119 @@ +#include "user_profile.h" + +using flutter::EncodableMap; +using flutter::EncodableValue; +using flutter::EncodableList; + +static bool IsCustomClaim(const std::string& key) { + return key.rfind("https://", 0) == 0; +} + +static std::optional GetString( + const EncodableMap& map, + const std::string& key) { + auto it = map.find(EncodableValue(key)); + if (it == map.end()) return std::nullopt; + if (!std::holds_alternative(it->second)) return std::nullopt; + return std::get(it->second); +} + +static bool GetBoolOrFalse( + const EncodableMap& map, + const std::string& key) { + auto it = map.find(EncodableValue(key)); + if (it == map.end()) return false; + if (!std::holds_alternative(it->second)) return false; + return std::get(it->second); +} + +UserProfile UserProfile::DeserializeUserProfile(const EncodableMap& payload) { + UserProfile profile; + + profile.id = GetString(payload, "user_id"); + profile.name = GetString(payload, "name"); + profile.nickname = GetString(payload, "nickname"); + profile.pictureURL = GetString(payload, "picture"); + profile.email = GetString(payload, "email"); + profile.givenName = GetString(payload, "given_name"); + profile.familyName = GetString(payload, "family_name"); + profile.isEmailVerified = GetBoolOrFalse(payload, "email_verified"); + + // identities +auto identities_it = payload.find(flutter::EncodableValue("identities")); +if (identities_it != payload.end() && + std::holds_alternative(identities_it->second)) { + + const auto& list = + std::get(identities_it->second); + + for (const auto& item : list) { + if (std::holds_alternative(item)) { + const auto& map = + std::get(item); + + profile.identities.emplace_back( + UserIdentity::FromEncodable(map) + ); + } + } +} + + + // user_metadata + auto userMetaIt = payload.find(EncodableValue("user_metadata")); + if (userMetaIt != payload.end() && + std::holds_alternative(userMetaIt->second)) { + profile.userMetadata = std::get(userMetaIt->second); + } + + // app_metadata + auto appMetaIt = payload.find(EncodableValue("app_metadata")); + if (appMetaIt != payload.end() && + std::holds_alternative(appMetaIt->second)) { + profile.appMetadata = std::get(appMetaIt->second); + } + + profile.extraInfo = payload; + return profile; +} + +flutter::EncodableMap UserProfile::ToMap() const { + EncodableMap map; + + auto get = [&](const char* key) -> EncodableValue { + auto it = extraInfo.find(EncodableValue(key)); + return it != extraInfo.end() ? it->second : EncodableValue(); + }; + + map[EncodableValue("sub")] = get("sub"); + map[EncodableValue("name")] = get("name"); + map[EncodableValue("given_name")] = get("given_name"); + map[EncodableValue("family_name")] = get("family_name"); + map[EncodableValue("nickname")] = get("nickname"); + map[EncodableValue("picture")] = get("picture"); + map[EncodableValue("email")] = get("email"); + map[EncodableValue("email_verified")] = get("email_verified"); + + EncodableMap customClaims; + for (const auto& kv : extraInfo) { + if (std::holds_alternative(kv.first)) { + const auto& key = std::get(kv.first); + if (IsCustomClaim(key)) { + customClaims[kv.first] = kv.second; + } + } + } + + map[EncodableValue("custom_claims")] = customClaims; + return map; +} + +std::optional UserProfile::GetId() const { + if (id) return id; + auto it = extraInfo.find(EncodableValue("sub")); + if (it != extraInfo.end() && + std::holds_alternative(it->second)) { + return std::get(it->second); + } + return std::nullopt; +} diff --git a/auth0_flutter/windows/user_profile.h b/auth0_flutter/windows/user_profile.h new file mode 100644 index 000000000..c1dc7893d --- /dev/null +++ b/auth0_flutter/windows/user_profile.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include "user_identity.h" + +class UserProfile { + public: + std::optional id; + std::optional name; + std::optional nickname; + std::optional pictureURL; + std::optional email; + std::optional isEmailVerified; + std::optional familyName; + std::optional givenName; + + std::vector identities; + flutter::EncodableMap userMetadata; + flutter::EncodableMap appMetadata; + flutter::EncodableMap extraInfo; + + static UserProfile DeserializeUserProfile(const flutter::EncodableMap& payload); + flutter::EncodableMap ToMap() const; + std::optional GetId() const; +}; \ No newline at end of file From d199d287ac03add0d040dd1124557a6786cc7340 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Mon, 5 Jan 2026 00:49:09 +0000 Subject: [PATCH 13/18] adds logout code --- auth0_flutter/windows/CMakeLists.txt | 1 - auth0_flutter/windows/auth0_client.cpp | 18 +- auth0_flutter/windows/auth0_client.h | 9 +- .../windows/auth0_flutter_plugin.cpp | 813 +++++++++++------- auth0_flutter/windows/auth0_flutter_plugin.h | 34 +- auth0_flutter/windows/credentials.cpp | 48 -- auth0_flutter/windows/credentials.h | 6 - auth0_flutter/windows/jwt_util.cpp | 50 +- auth0_flutter/windows/jwt_util.h | 11 +- auth0_flutter/windows/time_util.cpp | 16 +- auth0_flutter/windows/time_util.h | 4 +- auth0_flutter/windows/token_decoder.cpp | 28 +- auth0_flutter/windows/token_decoder.h | 2 +- 13 files changed, 595 insertions(+), 445 deletions(-) delete mode 100644 auth0_flutter/windows/credentials.cpp diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index 43d395550..54fcd8d74 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -38,7 +38,6 @@ add_library(${PLUGIN_NAME} SHARED "time_util.cpp" "user_profile.cpp" "user_identity.cpp" - "credentials.cpp" "jwt_util.cpp" ${PLUGIN_SOURCES} ) diff --git a/auth0_flutter/windows/auth0_client.cpp b/auth0_flutter/windows/auth0_client.cpp index 11d44f3f3..dfcf0c4fe 100644 --- a/auth0_flutter/windows/auth0_client.cpp +++ b/auth0_flutter/windows/auth0_client.cpp @@ -9,9 +9,11 @@ using namespace web::http; using namespace web::http::client; static std::string GetJsonString( - const web::json::value& json, - const utility::string_t& key) { - if (json.has_field(key) && json.at(key).is_string()) { + const web::json::value &json, + const utility::string_t &key) +{ + if (json.has_field(key) && json.at(key).is_string()) + { return utility::conversions::to_utf8string(json.at(key).as_string()); } return {}; @@ -22,9 +24,10 @@ Auth0Client::Auth0Client(std::string domain, std::string clientId) clientId_(std::move(clientId)) {} Credentials Auth0Client::ExchangeCodeForTokens( - const std::string& redirectUri, - const std::string& code, - const std::string& codeVerifier) { + const std::string &redirectUri, + const std::string &code, + const std::string &codeVerifier) +{ http_client client( U("https://" + utility::conversions::to_string_t(domain_))); @@ -49,7 +52,8 @@ Credentials Auth0Client::ExchangeCodeForTokens( auto response = client.request(request).get(); auto json = response.extract_json().get(); - if (response.status_code() != status_codes::OK) { + if (response.status_code() != status_codes::OK) + { throw std::runtime_error( "Token request failed: " + GetJsonString(json, U("error_description"))); diff --git a/auth0_flutter/windows/auth0_client.h b/auth0_flutter/windows/auth0_client.h index 6ee4d1f36..213110fc5 100644 --- a/auth0_flutter/windows/auth0_client.h +++ b/auth0_flutter/windows/auth0_client.h @@ -3,14 +3,15 @@ #include #include "credentials.h" -class Auth0Client { +class Auth0Client +{ public: Auth0Client(std::string domain, std::string clientId); Credentials ExchangeCodeForTokens( - const std::string& redirectUri, - const std::string& code, - const std::string& codeVerifier); + const std::string &redirectUri, + const std::string &code, + const std::string &codeVerifier); private: std::string domain_; diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index 8337fce41..c62259a5a 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -41,209 +41,257 @@ using namespace web::http; using namespace web::http::client; using namespace web::http::experimental::listener; -namespace auth0_flutter { - -// -------------------- PKCE Helpers -------------------- - -// Base64 URL-safe encode without padding -// Helper: Base64 URL-safe encode (no padding, + → -, / → _) -std::string base64UrlEncode(const std::vector& data) { - static const char* b64chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - std::string result; - size_t i = 0; - unsigned char a3[3]; - unsigned char a4[4]; - - for (size_t pos = 0; pos < data.size();) { - int len = 0; - for (i = 0; i < 3; i++) { - if (pos < data.size()) { - a3[i] = data[pos++]; - len++; - } else { - a3[i] = 0; +namespace auth0_flutter +{ + + // -------------------- PKCE Helpers -------------------- + + // Base64 URL-safe encode without padding + // Helper: Base64 URL-safe encode (no padding, + → -, / → _) + std::string base64UrlEncode(const std::vector &data) + { + static const char *b64chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string result; + size_t i = 0; + unsigned char a3[3]; + unsigned char a4[4]; + + for (size_t pos = 0; pos < data.size();) + { + int len = 0; + for (i = 0; i < 3; i++) + { + if (pos < data.size()) + { + a3[i] = data[pos++]; + len++; + } + else + { + a3[i] = 0; + } } - } - a4[0] = (a3[0] & 0xfc) >> 2; - a4[1] = ((a3[0] & 0x03) << 4) + ((a3[1] & 0xf0) >> 4); - a4[2] = ((a3[1] & 0x0f) << 2) + ((a3[2] & 0xc0) >> 6); - a4[3] = a3[2] & 0x3f; + a4[0] = (a3[0] & 0xfc) >> 2; + a4[1] = ((a3[0] & 0x03) << 4) + ((a3[1] & 0xf0) >> 4); + a4[2] = ((a3[1] & 0x0f) << 2) + ((a3[2] & 0xc0) >> 6); + a4[3] = a3[2] & 0x3f; - for (i = 0; i < 4; i++) { - if (i <= (size_t)(len + 0)) { - result += b64chars[a4[i]]; - } else { - result += '='; + for (i = 0; i < 4; i++) + { + if (i <= (size_t)(len + 0)) + { + result += b64chars[a4[i]]; + } + else + { + result += '='; + } } } - } - // Make it URL-safe - for (auto& c : result) { - if (c == '+') c = '-'; - if (c == '/') c = '_'; + // Make it URL-safe + for (auto &c : result) + { + if (c == '+') + c = '-'; + if (c == '/') + c = '_'; + } + + // Strip padding '=' + while (!result.empty() && result.back() == '=') + { + result.pop_back(); + } + + return result; } - // Strip padding '=' - while (!result.empty() && result.back() == '=') { - result.pop_back(); + // Generate random code verifier (32 bytes -> URL-safe string) + std::string generateCodeVerifier() + { + std::vector buffer(32); + if (RAND_bytes(buffer.data(), static_cast(buffer.size())) != 1) + { + throw std::runtime_error("Failed to generate random bytes"); + } + return base64UrlEncode(buffer); } - return result; -} + // Generate code challenge from verifier (SHA256 + base64URL) + std::string generateCodeChallenge(const std::string &verifier) + { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(verifier.data()), + verifier.size(), + hash); -// Generate random code verifier (32 bytes -> URL-safe string) -std::string generateCodeVerifier() { - std::vector buffer(32); - if (RAND_bytes(buffer.data(), static_cast(buffer.size())) != 1) { - throw std::runtime_error("Failed to generate random bytes"); + std::vector digest(hash, hash + SHA256_DIGEST_LENGTH); + return base64UrlEncode(digest); } - return base64UrlEncode(buffer); -} - -// Generate code challenge from verifier (SHA256 + base64URL) -std::string generateCodeChallenge(const std::string& verifier) { - unsigned char hash[SHA256_DIGEST_LENGTH]; - SHA256(reinterpret_cast(verifier.data()), - verifier.size(), - hash); - - std::vector digest(hash, hash + SHA256_DIGEST_LENGTH); - return base64UrlEncode(digest); -} - - -// ---------- Helpers: URL-decode, safe query parse, and waitForAuthCode (custom scheme) ---------- - -static std::string UrlDecode(const std::string& str) { - std::string out; - out.reserve(str.size()); - for (size_t i = 0; i < str.size(); ++i) { - char c = str[i]; - if (c == '%') { - if (i + 2 < str.size()) { - std::string hex = str.substr(i + 1, 2); - char decoded = (char)strtol(hex.c_str(), nullptr, 16); - out.push_back(decoded); - i += 2; + + // ---------- Helpers: URL-decode, safe query parse, and waitForAuthCode (custom scheme) ---------- + + static std::string UrlDecode(const std::string &str) + { + std::string out; + out.reserve(str.size()); + for (size_t i = 0; i < str.size(); ++i) + { + char c = str[i]; + if (c == '%') + { + if (i + 2 < str.size()) + { + std::string hex = str.substr(i + 1, 2); + char decoded = (char)strtol(hex.c_str(), nullptr, 16); + out.push_back(decoded); + i += 2; + } + // else malformed percent-encoding: skip + } + else if (c == '+') + { + out.push_back(' '); + } + else + { + out.push_back(c); } - // else malformed percent-encoding: skip - } else if (c == '+') { - out.push_back(' '); - } else { - out.push_back(c); - } - } - return out; -} - -static std::map SafeParseQuery(const std::string& query) { - std::map params; - size_t start = 0; - while (start < query.size()) { - size_t eq = query.find('=', start); - if (eq == std::string::npos) { - break; // no more key=value pairs - } - std::string key = query.substr(start, eq - start); - size_t amp = query.find('&', eq + 1); - std::string value; - if (amp == std::string::npos) { - value = query.substr(eq + 1); - start = query.size(); - } else { - value = query.substr(eq + 1, amp - (eq + 1)); - start = amp + 1; } - params[UrlDecode(key)] = UrlDecode(value); + return out; } - return params; -} - -// Safe UTF conversions (wchar_t <-> UTF-8) -static std::string WideToUtf8(const std::wstring& wstr) { - if (wstr.empty()) return {}; - int size_needed = ::WideCharToMultiByte(CP_UTF8, 0, wstr.data(), - (int)wstr.size(), nullptr, 0, nullptr, nullptr); - if (size_needed <= 0) return {}; - std::string str(size_needed, 0); - ::WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), &str[0], size_needed, nullptr, nullptr); - return str; -} - -// Poll environment variable PLUGIN_STARTUP_URL for redirect URI (set by runner/main on startup or IPC). -// Example stored value: auth0flutter://callback?code=AUTH_CODE&state=xyz -static std::string waitForAuthCode_CustomScheme(const std::string& expectedRedirectBase, int timeoutSeconds = 180) { - const int sleepMs = 200; - int elapsed = 0; -auto readAndClearEnv = []() -> std::string { - // Ask Windows how many wchar_t characters are needed (including null) - DWORD bufSize = GetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", NULL, 0); - if (bufSize == 0) return std::string(); - - std::vector buf(bufSize); - DWORD ret = GetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", buf.data(), bufSize); - if (ret == 0 || ret >= bufSize) { - return std::string(); - } - - // Clear it so it's not consumed twice - SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", L""); - - // Convert wide -> UTF-8 safely - std::wstring wstr(buf.data(), ret); - return WideToUtf8(wstr); -}; - - while (elapsed < timeoutSeconds * 1000) { - std::string uri = readAndClearEnv(); - if (!uri.empty()) { - // DebugPrint("Received startup URI: " + uri); - // Optionally: verify prefix matches expectedRedirectBase (e.g. "auth0flutter://callback") - if (!expectedRedirectBase.empty()) { - if (uri.rfind(expectedRedirectBase, 0) != 0) { - // DebugPrint("Warning: received URI does not start with expected redirect base"); - // continue — but still try to parse if present - } + + static std::map SafeParseQuery(const std::string &query) + { + std::map params; + size_t start = 0; + while (start < query.size()) + { + size_t eq = query.find('=', start); + if (eq == std::string::npos) + { + break; // no more key=value pairs } - // find query - auto qpos = uri.find('?'); - if (qpos == std::string::npos) { - return std::string(); // no query params + std::string key = query.substr(start, eq - start); + size_t amp = query.find('&', eq + 1); + std::string value; + if (amp == std::string::npos) + { + value = query.substr(eq + 1); + start = query.size(); } - std::string query = uri.substr(qpos + 1); - auto params = SafeParseQuery(query); - auto it = params.find("code"); - if (it != params.end()) { - return it->second; - } else { - // maybe error param present - if (params.find("error") != params.end()) { - // DebugPrint("OAuth returned error: " + params["error"]); - return std::string(); - } + else + { + value = query.substr(eq + 1, amp - (eq + 1)); + start = amp + 1; } + params[UrlDecode(key)] = UrlDecode(value); } - std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); - elapsed += sleepMs; + return params; } - // timeout - return std::string(); -} + // Safe UTF conversions (wchar_t <-> UTF-8) + static std::string WideToUtf8(const std::wstring &wstr) + { + if (wstr.empty()) + return {}; + int size_needed = ::WideCharToMultiByte(CP_UTF8, 0, wstr.data(), + (int)wstr.size(), nullptr, 0, nullptr, nullptr); + if (size_needed <= 0) + return {}; + std::string str(size_needed, 0); + ::WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), &str[0], size_needed, nullptr, nullptr); + return str; + } + // Poll environment variable PLUGIN_STARTUP_URL for redirect URI (set by runner/main on startup or IPC). + // Example stored value: auth0flutter://callback?code=AUTH_CODE&state=xyz + static std::string waitForAuthCode_CustomScheme(const std::string &expectedRedirectBase, int timeoutSeconds = 180) + { + const int sleepMs = 200; + int elapsed = 0; + auto readAndClearEnv = []() -> std::string + { + // Ask Windows how many wchar_t characters are needed (including null) + DWORD bufSize = GetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", NULL, 0); + if (bufSize == 0) + return std::string(); + + std::vector buf(bufSize); + DWORD ret = GetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", buf.data(), bufSize); + if (ret == 0 || ret >= bufSize) + { + return std::string(); + } -// -------------------- Local Redirect Listener -------------------- + // Clear it so it's not consumed twice + SetEnvironmentVariableW(L"PLUGIN_STARTUP_URL", L""); + + // Convert wide -> UTF-8 safely + std::wstring wstr(buf.data(), ret); + return WideToUtf8(wstr); + }; + + while (elapsed < timeoutSeconds * 1000) + { + std::string uri = readAndClearEnv(); + if (!uri.empty()) + { + // DebugPrint("Received startup URI: " + uri); + // Optionally: verify prefix matches expectedRedirectBase (e.g. "auth0flutter://callback") + if (!expectedRedirectBase.empty()) + { + if (uri.rfind(expectedRedirectBase, 0) != 0) + { + // DebugPrint("Warning: received URI does not start with expected redirect base"); + // continue — but still try to parse if present + } + } + // find query + auto qpos = uri.find('?'); + if (qpos == std::string::npos) + { + return std::string(); // no query params + } + std::string query = uri.substr(qpos + 1); + auto params = SafeParseQuery(query); + auto it = params.find("code"); + if (it != params.end()) + { + return it->second; + } + else + { + // maybe error param present + if (params.find("error") != params.end()) + { + // DebugPrint("OAuth returned error: " + params["error"]); + return std::string(); + } + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + elapsed += sleepMs; + } + + // timeout + return std::string(); + } -std::string waitForAuthCode(const std::string& redirectUri) { - uri u(utility::conversions::to_string_t(redirectUri)); - http_listener listener(u); + // -------------------- Local Redirect Listener -------------------- - std::string authCode; + std::string waitForAuthCode(const std::string &redirectUri) + { + uri u(utility::conversions::to_string_t(redirectUri)); + http_listener listener(u); - listener.support(methods::GET, [&](http_request request) { + std::string authCode; + + listener.support(methods::GET, [&](http_request request) + { auto queries = uri::split_query(request.request_uri().query()); auto it = queries.find(U("code")); if (it != queries.end()) { @@ -251,179 +299,300 @@ std::string waitForAuthCode(const std::string& redirectUri) { } request.reply(status_codes::OK, - U("Login successful! You may close this window.")); - }); + U("Login successful! You may close this window.")); }); - listener.open().wait(); + listener.open().wait(); - while (authCode.empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - listener.close().wait(); - return authCode; -} + while (authCode.empty()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + listener.close().wait(); + return authCode; + } -// -------------------- Token Exchange -------------------- + // -------------------- Token Exchange -------------------- + // -------------------- Plugin Impl -------------------- + void Auth0FlutterPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) + { + auto channel = + std::make_unique>( + registrar->messenger(), "auth0.com/auth0_flutter/web_auth", + &flutter::StandardMethodCodec::GetInstance()); -// -------------------- Plugin Impl -------------------- + auto plugin = std::make_unique(); -void Auth0FlutterPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows *registrar) { - auto channel = - std::make_unique>( - registrar->messenger(), "auth0.com/auth0_flutter/web_auth", - &flutter::StandardMethodCodec::GetInstance()); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) + { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); - auto plugin = std::make_unique(); + registrar->AddPlugin(std::move(plugin)); + } - channel->SetMethodCallHandler( - [plugin_pointer = plugin.get()](const auto &call, auto result) { - plugin_pointer->HandleMethodCall(call, std::move(result)); - }); + Auth0FlutterPlugin::Auth0FlutterPlugin() {} + Auth0FlutterPlugin::~Auth0FlutterPlugin() {} - registrar->AddPlugin(std::move(plugin)); -} + void DebugPrint(const std::string &msg) + { + OutputDebugStringA((msg + "\n").c_str()); + } -Auth0FlutterPlugin::Auth0FlutterPlugin() {} -Auth0FlutterPlugin::~Auth0FlutterPlugin() {} + static std::ostringstream BuildLogoutUrl( + const std::string &domain, + const std::string &clientId, + const std::string &returnTo, + bool federated) + { + std::ostringstream url; -void DebugPrint(const std::string& msg) { - OutputDebugStringA((msg + "\n").c_str()); -} + url << "https://" << domain << "/v2/logout"; -void Auth0FlutterPlugin::HandleMethodCall( - const flutter::MethodCall &method_call, - std::unique_ptr> result) { - if (method_call.method_name().compare("webAuth#login") == 0) { - // Top-level args should be a map - const auto* args = std::get_if(method_call.arguments()); - if (!args) { - result->Error("bad_args", "Expected a map as arguments"); - return; - } + // Swift: v2/logout?federated + if (federated) + { + url << "?federated"; + } - // Extract "account" map - auto accountIt = args->find(flutter::EncodableValue("_account")); - if (accountIt == args->end()) { - result->Error("bad_args", "Missing 'account' key"); - return; - } + // Append query params + char separator = federated ? '&' : '?'; - const auto* accountMap = std::get_if(&accountIt->second); - if (!accountMap) { - result->Error("bad_args", "'account' is not a map"); - return; - } + if (!returnTo.empty()) + { + url << separator << "returnTo=" << returnTo; + separator = '&'; + } - // Extract clientId and domain - std::string clientId; - std::string domain; + url << separator << "client_id=" << clientId; - if (auto it = accountMap->find(flutter::EncodableValue("clientId")); - it != accountMap->end()) { - clientId = std::get(it->second); + return url; } - if (auto it = accountMap->find(flutter::EncodableValue("domain")); - it != accountMap->end()) { - domain = std::get(it->second); - } + void Auth0FlutterPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) + { + if (method_call.method_name().compare("webAuth#login") == 0) + { + // Top-level args should be a map + const auto *args = std::get_if(method_call.arguments()); + if (!args) + { + result->Error("bad_args", "Expected a map as arguments"); + return; + } -std::string scopeStr = "openid profile email"; // default + // Extract "account" map + auto accountIt = args->find(flutter::EncodableValue("_account")); + if (accountIt == args->end()) + { + result->Error("bad_args", "Missing 'account' key"); + return; + } -auto scopesIt = args->find(flutter::EncodableValue("scopes")); -if (scopesIt != args->end()) { - const auto* scopeList = - std::get_if(&scopesIt->second); - if (!scopeList) { - result->Error("bad_args", "'scopes' must be a List"); - return; - } + const auto *accountMap = std::get_if(&accountIt->second); + if (!accountMap) + { + result->Error("bad_args", "'account' is not a map"); + return; + } - std::ostringstream oss; - bool first = true; - for (const auto& v : *scopeList) { - const auto* s = std::get_if(&v); - if (!s) { - result->Error("bad_args", "Each scope must be a String"); - return; - } - if (!first) oss << " "; - oss << *s; - first = false; - } + // Extract clientId and domain + std::string clientId; + std::string domain; - scopeStr = oss.str(); -} - std::string redirectUri = "auth0flutter://callback"; - - try { - // 1. PKCE - std::string codeVerifier = generateCodeVerifier(); - std::string codeChallenge = generateCodeChallenge(codeVerifier); - DebugPrint("codeVerifier = " + codeVerifier); - DebugPrint("codeChallenge = " + codeChallenge); - // 2. Build Auth URL - std::ostringstream authUrl; - authUrl << "https://" << domain << "/authorize?" - << "response_type=code" - << "&client_id=" << clientId - << "&redirect_uri=" << redirectUri - << "&scope=" << scopeStr - << "&code_challenge=" << codeChallenge - << "&code_challenge_method=S256"; - - // 3. Open browser - ShellExecuteA(NULL, "open", authUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); - - // 4. Wait for callback - std::string code = waitForAuthCode_CustomScheme(redirectUri, 180); - - // 5. Exchange code for tokens - Auth0Client client(domain, clientId); - Credentials creds = client.ExchangeCodeForTokens(redirectUri, code, codeVerifier); - flutter::EncodableMap response; - - response[flutter::EncodableValue("accessToken")] = - flutter::EncodableValue(creds.accessToken); - -response[flutter::EncodableValue("idToken")] = - flutter::EncodableValue(creds.idToken); - - if (creds.refreshToken.has_value()) { -response[flutter::EncodableValue("refreshToken")] = - flutter::EncodableValue(creds.refreshToken.value()); - } + if (auto it = accountMap->find(flutter::EncodableValue("clientId")); + it != accountMap->end()) + { + clientId = std::get(it->second); + } + + if (auto it = accountMap->find(flutter::EncodableValue("domain")); + it != accountMap->end()) + { + domain = std::get(it->second); + } + + std::string scopeStr = "openid profile email"; // default + + auto scopesIt = args->find(flutter::EncodableValue("scopes")); + if (scopesIt != args->end()) + { + const auto *scopeList = + std::get_if(&scopesIt->second); + if (!scopeList) + { + result->Error("bad_args", "'scopes' must be a List"); + return; + } + + std::ostringstream oss; + bool first = true; + for (const auto &v : *scopeList) + { + const auto *s = std::get_if(&v); + if (!s) + { + result->Error("bad_args", "Each scope must be a String"); + return; + } + if (!first) + oss << " "; + oss << *s; + first = false; + } + + scopeStr = oss.str(); + } + std::string redirectUri = "auth0flutter://callback"; + + try + { + // 1. PKCE + std::string codeVerifier = generateCodeVerifier(); + std::string codeChallenge = generateCodeChallenge(codeVerifier); + DebugPrint("codeVerifier = " + codeVerifier); + DebugPrint("codeChallenge = " + codeChallenge); + // 2. Build Auth URL + std::ostringstream authUrl; + authUrl << "https://" << domain << "/authorize?" + << "response_type=code" + << "&client_id=" << clientId + << "&redirect_uri=" << redirectUri + << "&scope=" << scopeStr + << "&code_challenge=" << codeChallenge + << "&code_challenge_method=S256"; + + // 3. Open browser + ShellExecuteA(NULL, "open", authUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); + + // 4. Wait for callback + std::string code = waitForAuthCode_CustomScheme(redirectUri, 180); + + // 5. Exchange code for tokens + Auth0Client client(domain, clientId); + Credentials creds = client.ExchangeCodeForTokens(redirectUri, code, codeVerifier); + flutter::EncodableMap response; + + response[flutter::EncodableValue("accessToken")] = + flutter::EncodableValue(creds.accessToken); + + response[flutter::EncodableValue("idToken")] = + flutter::EncodableValue(creds.idToken); + + if (creds.refreshToken.has_value()) + { + response[flutter::EncodableValue("refreshToken")] = + flutter::EncodableValue(creds.refreshToken.value()); + } + + response[flutter::EncodableValue("tokenType")] = + flutter::EncodableValue(creds.tokenType); + + if (creds.expiresAt.has_value()) + { + response[flutter::EncodableValue("expiresAt")] = + flutter::EncodableValue(ToIso8601(creds.expiresAt.value())); + } + flutter::EncodableList scopes; + for (const auto &credscope : creds.scope) + { + scopes.emplace_back(credscope); // scope must be std::string + } + + response[flutter::EncodableValue("scopes")] = flutter::EncodableValue(scopes); -response[flutter::EncodableValue("tokenType")] = - flutter::EncodableValue(creds.tokenType); + web::json::value payload_json = DecodeJwtPayload(creds.idToken); + auto ev = JsonToEncodable(payload_json); + auto payload_map = std::get(ev); + UserProfile user = UserProfile::DeserializeUserProfile(payload_map); + response[flutter::EncodableValue("userProfile")] = flutter::EncodableValue(user.ToMap()); -if (creds.expiresAt.has_value()) { - response[flutter::EncodableValue("expiresAt")] = - flutter::EncodableValue(ToIso8601(creds.expiresAt.value())); -} - flutter::EncodableList scopes; - for (const auto& credscope : creds.scope) { - scopes.emplace_back(credscope); // scope must be std::string + result->Success(flutter::EncodableValue(response)); + } + catch (const std::exception &e) + { + result->Error("auth_failed", e.what()); + } } + else if (method_call.method_name().compare("webAuth#logout") == 0) + { + // Top-level args should be a map + const auto *args = std::get_if(method_call.arguments()); + if (!args) + { + result->Error("bad_args", "Expected a map as arguments"); + return; + } - response[flutter::EncodableValue("scopes")] = flutter::EncodableValue(scopes); + // Extract "account" map + auto accountIt = args->find(flutter::EncodableValue("_account")); + if (accountIt == args->end()) + { + result->Error("bad_args", "Missing 'account' key"); + return; + } - web::json::value payload_json = DecodeJwtPayload(creds.idToken); - auto ev = JsonToEncodable(payload_json); - auto payload_map = std::get(ev); - UserProfile user = UserProfile::DeserializeUserProfile(payload_map); - response[flutter::EncodableValue("userProfile")] = flutter::EncodableValue(user.ToMap()); + const auto *accountMap = std::get_if(&accountIt->second); + if (!accountMap) + { + result->Error("bad_args", "'account' is not a map"); + return; + } - result->Success(flutter::EncodableValue(response)); - } catch (const std::exception& e) { - result->Error("auth_failed", e.what()); - } - } else { - result->NotImplemented(); - } + // Extract clientId and domain + std::string clientId; + std::string domain; + + if (auto it = accountMap->find(flutter::EncodableValue("clientId")); + it != accountMap->end()) + { + clientId = std::get(it->second); + } + + if (auto it = accountMap->find(flutter::EncodableValue("domain")); + it != accountMap->end()) + { + domain = std::get(it->second); + } + + std::string returnTo = "auth0flutter://callback"; + + auto it = args->find(flutter::EncodableValue("returnTo")); + if (it != args->end()) + { + if (auto s = std::get_if(&it->second)) + { + returnTo = *s; + } + } + bool federated = false; + auto fedIt = args->find(flutter::EncodableValue("federated")); + if (fedIt != args->end()) + { + if (auto b = std::get_if(&fedIt->second)) + { + federated = *b; + } + } + + std::ostringstream logoutUrl = BuildLogoutUrl( + domain, + clientId, + returnTo, + federated); + + ShellExecuteA(NULL, "open", logoutUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); + result->Success(flutter::EncodableValue()); + } + else + { + result->NotImplemented(); + } } -} // namespace auth0_flutter \ No newline at end of file +} // namespace auth0_flutter \ No newline at end of file diff --git a/auth0_flutter/windows/auth0_flutter_plugin.h b/auth0_flutter/windows/auth0_flutter_plugin.h index 4f1c82591..3923ac0ea 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.h +++ b/auth0_flutter/windows/auth0_flutter_plugin.h @@ -6,26 +6,28 @@ #include -namespace auth0_flutter { +namespace auth0_flutter +{ -class Auth0FlutterPlugin : public flutter::Plugin { - public: - static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + class Auth0FlutterPlugin : public flutter::Plugin + { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); - Auth0FlutterPlugin(); + Auth0FlutterPlugin(); - virtual ~Auth0FlutterPlugin(); + virtual ~Auth0FlutterPlugin(); - // Disallow copy and assign. - Auth0FlutterPlugin(const Auth0FlutterPlugin&) = delete; - Auth0FlutterPlugin& operator=(const Auth0FlutterPlugin&) = delete; + // Disallow copy and assign. + Auth0FlutterPlugin(const Auth0FlutterPlugin &) = delete; + Auth0FlutterPlugin &operator=(const Auth0FlutterPlugin &) = delete; - // Called when a method is called on this plugin's channel from Dart. - void HandleMethodCall( - const flutter::MethodCall &method_call, - std::unique_ptr> result); -}; + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); + }; -} // namespace auth0_flutter +} // namespace auth0_flutter -#endif // FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ +#endif // FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ diff --git a/auth0_flutter/windows/credentials.cpp b/auth0_flutter/windows/credentials.cpp deleted file mode 100644 index 5eabe68a9..000000000 --- a/auth0_flutter/windows/credentials.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include "credentials.h" - -#include "jwt_util.h" -#include "time_util.h" - -// UserProfile Credentials::GetUser() const { - -// auto payloadJson = DecodeJwtPayload(idToken); -// return UserProfile::FromJwtPayload(payloadJson); -// } - -// flutter::EncodableMap Credentials::ToEncodableMap() const { -// flutter::EncodableMap map; - -// map[flutter::EncodableValue("accessToken")] = flutter::EncodableValue(accessToken); -// map[flutter::EncodableValue("idToken")] = flutter::EncodableValue(idToken); -// map[flutter::EncodableValue("tokenType")] = flutter::EncodableValue(tokenType); - -// if (refreshToken.has_value()) { -// map[flutter::EncodableValue("refreshToken")] = flutter::EncodableValue(*refreshToken); -// } - -// // expiresIn (seconds) -// if (expiresIn.has_value()) { -// map[flutter::EncodableValue("expiresIn")] = -// flutter::EncodableValue(static_cast(*expiresIn)); -// } - -// // expiresAt (ISO-8601 string, same as Android) -// if (expiresAt.has_value()) { -// map[flutter::EncodableValue("expiresAt")] = -// flutter::EncodableValue(ToIso8601(*expiresAt)); -// } - -// // scope list -// if (!scope.empty()) { -// flutter::EncodableList scopes; -// for (const auto& s : scope) { -// scopes.emplace_back(s); -// } -// map[flutter::EncodableValue("scope")] = flutter::EncodableValue(scopes); -// } - -// // ✅ Computed user property -// map[flutter::EncodableValue("userProfile")] = flutter::EncodableValue(GetUser().ToEncodableMap()); - -// return map; -// } \ No newline at end of file diff --git a/auth0_flutter/windows/credentials.h b/auth0_flutter/windows/credentials.h index 6e3abb4ee..26ed58f6c 100644 --- a/auth0_flutter/windows/credentials.h +++ b/auth0_flutter/windows/credentials.h @@ -21,10 +21,4 @@ class Credentials { std::optional expiresAt; std::vector scope; - - // // ===== Computed properties ===== - // UserProfile GetUser() const; - - // // ===== Serialization ===== - // flutter::EncodableMap ToEncodableMap() const; }; diff --git a/auth0_flutter/windows/jwt_util.cpp b/auth0_flutter/windows/jwt_util.cpp index cff26dd3f..ad9b68743 100644 --- a/auth0_flutter/windows/jwt_util.cpp +++ b/auth0_flutter/windows/jwt_util.cpp @@ -8,11 +8,13 @@ #pragma comment(lib, "Crypt32.lib") -static std::string Base64UrlDecode(const std::string& input) { +static std::string Base64UrlDecode(const std::string &input) +{ std::string padded = input; std::replace(padded.begin(), padded.end(), '-', '+'); std::replace(padded.begin(), padded.end(), '_', '/'); - while (padded.size() % 4 != 0) padded.push_back('='); + while (padded.size() % 4 != 0) + padded.push_back('='); DWORD out_len = 0; CryptStringToBinaryA( @@ -29,7 +31,7 @@ static std::string Base64UrlDecode(const std::string& input) { padded.c_str(), static_cast(padded.size()), CRYPT_STRING_BASE64, - reinterpret_cast(&output[0]), + reinterpret_cast(&output[0]), &out_len, nullptr, nullptr); @@ -37,51 +39,64 @@ static std::string Base64UrlDecode(const std::string& input) { return output; } -JwtParts SplitJwt(const std::string& token) { +JwtParts SplitJwt(const std::string &token) +{ std::stringstream ss(token); std::string part; std::vector parts; - while (std::getline(ss, part, '.')) { + while (std::getline(ss, part, '.')) + { parts.push_back(part); } - if (parts.size() == 2 && !token.empty() && token.back() == '.') { + if (parts.size() == 2 && !token.empty() && token.back() == '.') + { parts.push_back(""); } - if (parts.size() != 3) { + if (parts.size() != 3) + { throw std::runtime_error("JWT must have exactly 3 parts"); } return {parts[0], parts[1], parts[2]}; } -web::json::value DecodeJwtPayload(const std::string& token) { +web::json::value DecodeJwtPayload(const std::string &token) +{ auto parts = SplitJwt(token); auto decoded = Base64UrlDecode(parts.payload); return web::json::value::parse(decoded); } -flutter::EncodableValue JsonToEncodable(const web::json::value& v) { - if (v.is_null()) return flutter::EncodableValue(); +flutter::EncodableValue JsonToEncodable(const web::json::value &v) +{ + if (v.is_null()) + return flutter::EncodableValue(); - if (v.is_boolean()) return flutter::EncodableValue(v.as_bool()); - if (v.is_number()) return flutter::EncodableValue(v.as_double()); + if (v.is_boolean()) + return flutter::EncodableValue(v.as_bool()); + if (v.is_number()) + return flutter::EncodableValue(v.as_double()); if (v.is_string()) return flutter::EncodableValue(utility::conversions::to_utf8string(v.as_string())); - if (v.is_array()) { + if (v.is_array()) + { flutter::EncodableList list; - for (const auto& item : v.as_array()) { + for (const auto &item : v.as_array()) + { list.push_back(JsonToEncodable(item)); } return flutter::EncodableValue(list); } - if (v.is_object()) { + if (v.is_object()) + { flutter::EncodableMap map; - for (const auto& kv : v.as_object()) { + for (const auto &kv : v.as_object()) + { map[flutter::EncodableValue(utility::conversions::to_utf8string(kv.first))] = JsonToEncodable(kv.second); } @@ -91,7 +106,8 @@ flutter::EncodableValue JsonToEncodable(const web::json::value& v) { return flutter::EncodableValue(); } -flutter::EncodableMap ParseJsonToEncodableMap(const std::string& json) { +flutter::EncodableMap ParseJsonToEncodableMap(const std::string &json) +{ auto parsed = web::json::value::parse(json); auto ev = JsonToEncodable(parsed); return std::get(ev); diff --git a/auth0_flutter/windows/jwt_util.h b/auth0_flutter/windows/jwt_util.h index 774d2755b..981cd338b 100644 --- a/auth0_flutter/windows/jwt_util.h +++ b/auth0_flutter/windows/jwt_util.h @@ -4,15 +4,16 @@ #include #include -struct JwtParts { +struct JwtParts +{ std::string header; std::string payload; std::string signature; }; -JwtParts SplitJwt(const std::string& token); -web::json::value DecodeJwtPayload(const std::string& token); +JwtParts SplitJwt(const std::string &token); +web::json::value DecodeJwtPayload(const std::string &token); // SAFE conversion -flutter::EncodableMap ParseJsonToEncodableMap(const std::string& json); -flutter::EncodableValue JsonToEncodable(const web::json::value& v); \ No newline at end of file +flutter::EncodableMap ParseJsonToEncodableMap(const std::string &json); +flutter::EncodableValue JsonToEncodable(const web::json::value &v); \ No newline at end of file diff --git a/auth0_flutter/windows/time_util.cpp b/auth0_flutter/windows/time_util.cpp index 0ed582e24..bdbb3cb1b 100644 --- a/auth0_flutter/windows/time_util.cpp +++ b/auth0_flutter/windows/time_util.cpp @@ -5,8 +5,10 @@ #include std::optional -ParseIso8601(const std::string& iso) { - if (iso.empty()) { +ParseIso8601(const std::string &iso) +{ + if (iso.empty()) + { return std::nullopt; } @@ -14,21 +16,23 @@ ParseIso8601(const std::string& iso) { std::istringstream ss(iso); ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); - if (ss.fail()) { + if (ss.fail()) + { return std::nullopt; } #if defined(_WIN32) - std::time_t t = _mkgmtime(&tm); // Windows UTC + std::time_t t = _mkgmtime(&tm); // Windows UTC #else - std::time_t t = timegm(&tm); // POSIX UTC + std::time_t t = timegm(&tm); // POSIX UTC #endif return std::chrono::system_clock::from_time_t(t); } std::string -ToIso8601(const std::chrono::system_clock::time_point& tp) { +ToIso8601(const std::chrono::system_clock::time_point &tp) +{ std::time_t t = std::chrono::system_clock::to_time_t(tp); std::tm tm{}; diff --git a/auth0_flutter/windows/time_util.h b/auth0_flutter/windows/time_util.h index fbc08eca2..24d1438e0 100644 --- a/auth0_flutter/windows/time_util.h +++ b/auth0_flutter/windows/time_util.h @@ -5,7 +5,7 @@ #include std::optional -ParseIso8601(const std::string& iso); +ParseIso8601(const std::string &iso); std::string -ToIso8601(const std::chrono::system_clock::time_point& tp); +ToIso8601(const std::chrono::system_clock::time_point &tp); diff --git a/auth0_flutter/windows/token_decoder.cpp b/auth0_flutter/windows/token_decoder.cpp index 6852a17ab..0bbc03c27 100644 --- a/auth0_flutter/windows/token_decoder.cpp +++ b/auth0_flutter/windows/token_decoder.cpp @@ -2,7 +2,8 @@ #include #include "time_util.h" Credentials DecodeTokenResponse( - const web::json::value& json) { + const web::json::value &json) +{ Credentials creds; @@ -16,26 +17,30 @@ Credentials DecodeTokenResponse( json.at(U("token_type")).as_string()); // ---- Optional fields ---- - if (json.has_field(U("id_token"))) { + if (json.has_field(U("id_token"))) + { creds.idToken = utility::conversions::to_utf8string( json.at(U("id_token")).as_string()); } - if (json.has_field(U("refresh_token"))) { + if (json.has_field(U("refresh_token"))) + { creds.refreshToken = utility::conversions::to_utf8string( json.at(U("refresh_token")).as_string()); } - if (json.has_field(U("expires_in")) && - json.at(U("expires_in")).is_integer()) { + if (json.has_field(U("expires_in")) && + json.at(U("expires_in")).is_integer()) + { creds.expiresIn = json.at(U("expires_in")).as_integer(); } - + // Try expires_at from JSON if (json.has_field(U("expires_at")) && - json.at(U("expires_at")).is_string()) { + json.at(U("expires_at")).is_string()) + { auto iso = utility::conversions::to_utf8string( json.at(U("expires_at")).as_string()); @@ -44,7 +49,8 @@ Credentials DecodeTokenResponse( } // If expires_at missing, compute from expires_in - if (!creds.expiresAt.has_value() && creds.expiresIn.has_value()) { + if (!creds.expiresAt.has_value() && creds.expiresIn.has_value()) + { creds.expiresAt = std::chrono::system_clock::now() + std::chrono::seconds(creds.expiresIn.value()); @@ -54,14 +60,16 @@ Credentials DecodeTokenResponse( // scope (optional, space-separated string) if (json.has_field(U("scope")) && - json.at(U("scope")).is_string()) { + json.at(U("scope")).is_string()) + { auto scopeStr = utility::conversions::to_utf8string( json.at(U("scope")).as_string()); std::istringstream iss(scopeStr); std::string s; - while (iss >> s) { + while (iss >> s) + { creds.scope.push_back(s); } } diff --git a/auth0_flutter/windows/token_decoder.h b/auth0_flutter/windows/token_decoder.h index 20ae82c0d..0a5bef81e 100644 --- a/auth0_flutter/windows/token_decoder.h +++ b/auth0_flutter/windows/token_decoder.h @@ -3,4 +3,4 @@ #include "credentials.h" Credentials DecodeTokenResponse( - const web::json::value& json); + const web::json::value &json); From 8699d4e222e1d9232820db6599098f548fdab5fb Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Thu, 8 Jan 2026 09:41:29 +0000 Subject: [PATCH 14/18] adds login to bring app window to the front on redirection --- .github/actions/setup-darwin/action.yml | 2 +- auth0_flutter/build.yaml | 8 ++ .../lib/src/mobile/web_authentication.dart | 2 +- auth0_flutter/windows/CMakeLists.txt | 46 ++++--- .../windows/auth0_flutter_plugin.cpp | 33 ++++- .../test/auth0_flutter_plugin_test.cpp | 43 ------- auth0_flutter/windows/test/jwt_util_test.cpp | 121 ++++++++++++++++++ 7 files changed, 193 insertions(+), 62 deletions(-) create mode 100644 auth0_flutter/build.yaml delete mode 100644 auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp create mode 100644 auth0_flutter/windows/test/jwt_util_test.cpp diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 41f5f3c85..feb486e15 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # pin@v1.269.0 + uses: ruby/setup-ruby@b90be12699fdfcbee4440c2bba85f6f460446bb0 # pin@v1.279.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true diff --git a/auth0_flutter/build.yaml b/auth0_flutter/build.yaml new file mode 100644 index 000000000..77c8114a5 --- /dev/null +++ b/auth0_flutter/build.yaml @@ -0,0 +1,8 @@ +## This file is required to prevent deleting the mock file for the `auth0_flutter_web_test.dart` when running the build_runner command +targets: + $default: + builders: + mockito|mockBuilder: + generate_for: + exclude: + - test/web/auth0_flutter_web_test.dart diff --git a/auth0_flutter/lib/src/mobile/web_authentication.dart b/auth0_flutter/lib/src/mobile/web_authentication.dart index 349ccd6d2..3cccc8a0a 100644 --- a/auth0_flutter/lib/src/mobile/web_authentication.dart +++ b/auth0_flutter/lib/src/mobile/web_authentication.dart @@ -114,7 +114,7 @@ class WebAuthentication { allowedBrowsers: allowedBrowsers, useDPoP: useDPoP))); - await _credentialsManager?.storeCredentials(credentials); + await _credentialsManager?.storeCredentials(credentials); return credentials; } diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index 54fcd8d74..b190cf13f 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -88,39 +88,53 @@ set(auth0_flutter_bundled_libraries ) # === Tests === -if (${include_${PROJECT_NAME}_tests}) - set(TEST_RUNNER "${PROJECT_NAME}_test") +option(AUTH0_FLUTTER_ENABLE_TESTS "Build auth0_flutter unit tests" ON) + +if (AUTH0_FLUTTER_ENABLE_TESTS) enable_testing() - # Add the Google Test dependency (still FetchContent, not vcpkg) + set(TEST_RUNNER auth0_flutter_tests) + include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/release-1.11.0.zip ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) - set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(googletest) - # Build test runner add_executable(${TEST_RUNNER} - test/auth0_flutter_plugin_test.cpp - ${PLUGIN_SOURCES} + test/jwt_util_test.cpp + + # Reuse plugin sources directly (NO flutter plugin entrypoints) + auth0_client.cpp + token_decoder.cpp + time_util.cpp + user_profile.cpp + user_identity.cpp + jwt_util.cpp + ) + + target_compile_features(${TEST_RUNNER} PRIVATE cxx_std_17) + + target_include_directories(${TEST_RUNNER} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/include ) - apply_standard_settings(${TEST_RUNNER}) - target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin gtest_main gmock - ) - - # flutter_wrapper_plugin has link dependencies on the Flutter DLL. - add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${FLUTTER_LIBRARY}" $ + cpprestsdk::cpprest + OpenSSL::SSL + OpenSSL::Crypto + Boost::system + Boost::date_time + Boost::regex ) include(GoogleTest) gtest_discover_tests(${TEST_RUNNER}) -endif() +endif() \ No newline at end of file diff --git a/auth0_flutter/windows/auth0_flutter_plugin.cpp b/auth0_flutter/windows/auth0_flutter_plugin.cpp index c62259a5a..d02f4cf79 100644 --- a/auth0_flutter/windows/auth0_flutter_plugin.cpp +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -109,6 +109,37 @@ namespace auth0_flutter return result; } + void BringFlutterWindowToFront() + { + HWND hwnd = GetActiveWindow(); + + if (!hwnd) + { + hwnd = GetForegroundWindow(); + } + + if (!hwnd) + return; + + // Restore if minimized + if (IsIconic(hwnd)) + { + ShowWindow(hwnd, SW_RESTORE); + } + + // Required trick to bypass foreground lock + DWORD currentThread = GetCurrentThreadId(); + DWORD foregroundThread = GetWindowThreadProcessId(GetForegroundWindow(), NULL); + + AttachThreadInput(foregroundThread, currentThread, TRUE); + + SetForegroundWindow(hwnd); + SetFocus(hwnd); + SetActiveWindow(hwnd); + + AttachThreadInput(foregroundThread, currentThread, FALSE); + } + // Generate random code verifier (32 bytes -> URL-safe string) std::string generateCodeVerifier() { @@ -472,7 +503,7 @@ namespace auth0_flutter // 4. Wait for callback std::string code = waitForAuthCode_CustomScheme(redirectUri, 180); - +BringFlutterWindowToFront(); // 5. Exchange code for tokens Auth0Client client(domain, clientId); Credentials creds = client.ExchangeCodeForTokens(redirectUri, code, codeVerifier); diff --git a/auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp b/auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp deleted file mode 100644 index e39a3a0f1..000000000 --- a/auth0_flutter/windows/test/auth0_flutter_plugin_test.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include -#include -#include - -#include -#include -#include - -#include "auth0_flutter_plugin.h" - -namespace auth0_flutter { -namespace test { - -namespace { - -using flutter::EncodableMap; -using flutter::EncodableValue; -using flutter::MethodCall; -using flutter::MethodResultFunctions; - -} // namespace - -TEST(Auth0FlutterPlugin, GetPlatformVersion) { - Auth0FlutterPlugin plugin; - // Save the reply value from the success callback. - std::string result_string; - plugin.HandleMethodCall( - MethodCall("getPlatformVersion", std::make_unique()), - std::make_unique>( - [&result_string](const EncodableValue* result) { - result_string = std::get(*result); - }, - nullptr, nullptr)); - - // Since the exact string varies by host, just ensure that it's a string - // with the expected format. - EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0); -} - -} // namespace test -} // namespace auth0_flutter diff --git a/auth0_flutter/windows/test/jwt_util_test.cpp b/auth0_flutter/windows/test/jwt_util_test.cpp new file mode 100644 index 000000000..e6ce917d2 --- /dev/null +++ b/auth0_flutter/windows/test/jwt_util_test.cpp @@ -0,0 +1,121 @@ +#include + +#include "jwt_util.h" + +// cpprestsdk +#include + +// Flutter +#include + +using web::json::value; + +/* + * Helper: Create a minimal valid JWT with a known payload. + * Header: {"alg":"none"} + * Payload: {"sub":"123","name":"John","admin":true} + * + * NOTE: Signature is empty (allowed by your SplitJwt logic) + */ +static std::string CreateTestJwt() { + // base64url(header) + std::string header = "eyJhbGciOiJub25lIn0"; + // base64url(payload) + std::string payload = + "eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlfQ"; + return header + "." + payload + "."; +} + +/* ---------------- SplitJwt ---------------- */ + +TEST(SplitJwtTest, ValidJwtSplitsIntoThreeParts) { + std::string jwt = "a.b.c"; + JwtParts parts = SplitJwt(jwt); + + EXPECT_EQ(parts.header, "a"); + EXPECT_EQ(parts.payload, "b"); + EXPECT_EQ(parts.signature, "c"); +} + +TEST(SplitJwtTest, TrailingDotProducesEmptySignature) { + std::string jwt = "a.b."; + JwtParts parts = SplitJwt(jwt); + + EXPECT_EQ(parts.header, "a"); + EXPECT_EQ(parts.payload, "b"); + EXPECT_EQ(parts.signature, ""); +} + +TEST(SplitJwtTest, InvalidJwtThrows) { + EXPECT_THROW(SplitJwt("only.one"), std::runtime_error); + EXPECT_THROW(SplitJwt("too.many.parts.here"), std::runtime_error); +} + +/* ---------------- DecodeJwtPayload ---------------- */ + +TEST(DecodeJwtPayloadTest, DecodesPayloadCorrectly) { + std::string jwt = CreateTestJwt(); + value payload = DecodeJwtPayload(jwt); + + ASSERT_TRUE(payload.is_object()); + EXPECT_EQ(payload.at(U("sub")).as_string(), U("123")); + EXPECT_EQ(payload.at(U("name")).as_string(), U("John")); + EXPECT_TRUE(payload.at(U("admin")).as_bool()); +} + +/* ---------------- JsonToEncodable ---------------- */ + +TEST(JsonToEncodableTest, ConvertsPrimitiveTypes) { + EXPECT_TRUE( + std::holds_alternative(JsonToEncodable(value::boolean(true)))); + EXPECT_TRUE( + std::holds_alternative(JsonToEncodable(value::number(1.5)))); + EXPECT_TRUE( + std::holds_alternative( + JsonToEncodable(value::string(U("hello"))))); +} + +TEST(JsonToEncodableTest, ConvertsArray) { + value arr = value::array({ + value::number(1), + value::string(U("two")), + value::boolean(true), + }); + + flutter::EncodableValue ev = JsonToEncodable(arr); + ASSERT_TRUE(std::holds_alternative(ev)); + + const auto& list = std::get(ev); + EXPECT_EQ(list.size(), 3u); +} + +TEST(JsonToEncodableTest, ConvertsObject) { + value obj; + obj[U("a")] = value::number(1); + obj[U("b")] = value::string(U("two")); + + flutter::EncodableValue ev = JsonToEncodable(obj); + ASSERT_TRUE(std::holds_alternative(ev)); + + const auto& map = std::get(ev); + EXPECT_EQ(map.size(), 2u); +} + +/* ---------------- ParseJsonToEncodableMap ---------------- */ + +TEST(ParseJsonToEncodableMapTest, ParsesJsonStringToEncodableMap) { + std::string json = R"({ + "name": "Alice", + "age": 30, + "admin": false + })"; + + flutter::EncodableMap map = ParseJsonToEncodableMap(json); + + EXPECT_EQ(std::get(map.at(flutter::EncodableValue("name"))), + "Alice"); + EXPECT_EQ(std::get(map.at(flutter::EncodableValue("age"))), + 30); + EXPECT_EQ(std::get(map.at(flutter::EncodableValue("admin"))), + false); +} \ No newline at end of file From f870e5e4d51327457e5519affbc87b2781d1b92d Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Thu, 8 Jan 2026 23:06:38 +0530 Subject: [PATCH 15/18] deleted few unused files --- .../auth0_flutter/Auth0FlutterPlugin.kt | 33 ------------------- .../auth0_flutter/Auth0FlutterPluginTest.kt | 27 --------------- .../ios/Classes/Auth0FlutterPlugin.swift | 19 ----------- .../ios/Resources/PrivacyInfo.xcprivacy | 14 -------- .../lib/auth0_flutter_method_channel.dart | 17 ---------- .../lib/auth0_flutter_platform_interface.dart | 29 ---------------- .../macos/Classes/Auth0FlutterPlugin.swift | 19 ----------- .../macos/Resources/PrivacyInfo.xcprivacy | 12 ------- .../auth0_flutter_method_channel_test.dart | 25 -------------- auth0_flutter/test/auth0_flutter_test.dart | 29 ---------------- 10 files changed, 224 deletions(-) delete mode 100644 auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt delete mode 100644 auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt delete mode 100644 auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift delete mode 100644 auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy delete mode 100644 auth0_flutter/lib/auth0_flutter_method_channel.dart delete mode 100644 auth0_flutter/lib/auth0_flutter_platform_interface.dart delete mode 100644 auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift delete mode 100644 auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy delete mode 100644 auth0_flutter/test/auth0_flutter_method_channel_test.dart delete mode 100644 auth0_flutter/test/auth0_flutter_test.dart diff --git a/auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt b/auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt deleted file mode 100644 index 90b561e5e..000000000 --- a/auth0_flutter/android/src/main/kotlin/com/example/auth0_flutter/Auth0FlutterPlugin.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.auth0_flutter - -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result - -/** Auth0FlutterPlugin */ -class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler { - /// The MethodChannel that will the communication between Flutter and native Android - /// - /// This local reference serves to register the plugin with the Flutter Engine and unregister it - /// when the Flutter Engine is detached from the Activity - private lateinit var channel : MethodChannel - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "auth0_flutter") - channel.setMethodCallHandler(this) - } - - override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else { - result.notImplemented() - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } -} diff --git a/auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt b/auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt deleted file mode 100644 index cbaaae32c..000000000 --- a/auth0_flutter/android/src/test/kotlin/com/example/auth0_flutter/Auth0FlutterPluginTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.auth0_flutter - -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlin.test.Test -import org.mockito.Mockito - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -internal class Auth0FlutterPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = Auth0FlutterPlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } -} diff --git a/auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift b/auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift deleted file mode 100644 index 539c9a69c..000000000 --- a/auth0_flutter/ios/Classes/Auth0FlutterPlugin.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Flutter -import UIKit - -public class Auth0FlutterPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "auth0_flutter", binaryMessenger: registrar.messenger()) - let instance = Auth0FlutterPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - result("iOS " + UIDevice.current.systemVersion) - default: - result(FlutterMethodNotImplemented) - } - } -} diff --git a/auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy b/auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy deleted file mode 100644 index a34b7e2e6..000000000 --- a/auth0_flutter/ios/Resources/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,14 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyAccessedAPITypes - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/auth0_flutter/lib/auth0_flutter_method_channel.dart b/auth0_flutter/lib/auth0_flutter_method_channel.dart deleted file mode 100644 index 2052b0b7d..000000000 --- a/auth0_flutter/lib/auth0_flutter_method_channel.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -import 'auth0_flutter_platform_interface.dart'; - -/// An implementation of [Auth0FlutterPlatform] that uses method channels. -class MethodChannelAuth0Flutter extends Auth0FlutterPlatform { - /// The method channel used to interact with the native platform. - @visibleForTesting - final methodChannel = const MethodChannel('auth0_flutter'); - - @override - Future getPlatformVersion() async { - final version = await methodChannel.invokeMethod('getPlatformVersion'); - return version; - } -} diff --git a/auth0_flutter/lib/auth0_flutter_platform_interface.dart b/auth0_flutter/lib/auth0_flutter_platform_interface.dart deleted file mode 100644 index 169ce5206..000000000 --- a/auth0_flutter/lib/auth0_flutter_platform_interface.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -import 'auth0_flutter_method_channel.dart'; - -abstract class Auth0FlutterPlatform extends PlatformInterface { - /// Constructs a Auth0FlutterPlatform. - Auth0FlutterPlatform() : super(token: _token); - - static final Object _token = Object(); - - static Auth0FlutterPlatform _instance = MethodChannelAuth0Flutter(); - - /// The default instance of [Auth0FlutterPlatform] to use. - /// - /// Defaults to [MethodChannelAuth0Flutter]. - static Auth0FlutterPlatform get instance => _instance; - - /// Platform-specific implementations should set this with their own - /// platform-specific class that extends [Auth0FlutterPlatform] when - /// they register themselves. - static set instance(final Auth0FlutterPlatform instance) { - PlatformInterface.verifyToken(instance, _token); - _instance = instance; - } - - Future getPlatformVersion() { - throw UnimplementedError('platformVersion() has not been implemented.'); - } -} diff --git a/auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift b/auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift deleted file mode 100644 index 0ba101c8b..000000000 --- a/auth0_flutter/macos/Classes/Auth0FlutterPlugin.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Cocoa -import FlutterMacOS - -public class Auth0FlutterPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "auth0_flutter", binaryMessenger: registrar.messenger) - let instance = Auth0FlutterPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) - default: - result(FlutterMethodNotImplemented) - } - } -} diff --git a/auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy b/auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy deleted file mode 100644 index 918d80be4..000000000 --- a/auth0_flutter/macos/Resources/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,12 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/auth0_flutter/test/auth0_flutter_method_channel_test.dart b/auth0_flutter/test/auth0_flutter_method_channel_test.dart deleted file mode 100644 index 328c7b1fd..000000000 --- a/auth0_flutter/test/auth0_flutter_method_channel_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:auth0_flutter/auth0_flutter_method_channel.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - final MethodChannelAuth0Flutter platform = MethodChannelAuth0Flutter(); - const MethodChannel channel = MethodChannel('auth0_flutter'); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - (final MethodCall methodCall) async => '42', - ); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); - }); - - test('getPlatformVersion', () async { - expect(await platform.getPlatformVersion(), '42'); - }); -} diff --git a/auth0_flutter/test/auth0_flutter_test.dart b/auth0_flutter/test/auth0_flutter_test.dart deleted file mode 100644 index 0bcd8f9e8..000000000 --- a/auth0_flutter/test/auth0_flutter_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:auth0_flutter/auth0_flutter.dart'; -import 'package:auth0_flutter/auth0_flutter_platform_interface.dart'; -import 'package:auth0_flutter/auth0_flutter_method_channel.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -class MockAuth0FlutterPlatform - with MockPlatformInterfaceMixin - implements Auth0FlutterPlatform { - - @override - Future getPlatformVersion() => Future.value('42'); -} - -void main() { - final Auth0FlutterPlatform initialPlatform = Auth0FlutterPlatform.instance; - - test('$MethodChannelAuth0Flutter is the default instance', () { - expect(initialPlatform, isInstanceOf()); - }); - - test('getPlatformVersion', () async { - final Auth0Flutter auth0FlutterPlugin = Auth0Flutter(); - final MockAuth0FlutterPlatform fakePlatform = MockAuth0FlutterPlatform(); - Auth0FlutterPlatform.instance = fakePlatform; - - expect(await auth0FlutterPlugin.getPlatformVersion(), '42'); - }); -} From 6fa485fff3c2977cb1e0ee50a9d71de37a4d1b31 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Tue, 20 Jan 2026 08:42:47 +0530 Subject: [PATCH 16/18] Add unit test and CI setup (cherry picked from commit 6ad8d90cd1dfe65d2b842c30185fcfdb3fb7eb0b) --- .github/workflows/main.yml | 42 +++ .../xcshareddata/xcschemes/Runner.xcscheme | 1 + auth0_flutter/windows/CMakeLists.txt | 4 + auth0_flutter/windows/test/time_util_test.cpp | 141 ++++++++ .../windows/test/token_decoder_test.cpp | 203 ++++++++++++ .../windows/test/user_identity_test.cpp | 255 ++++++++++++++ .../windows/test/user_profile_test.cpp | 313 ++++++++++++++++++ 7 files changed, 959 insertions(+) create mode 100644 auth0_flutter/windows/test/time_util_test.cpp create mode 100644 auth0_flutter/windows/test/token_decoder_test.cpp create mode 100644 auth0_flutter/windows/test/user_identity_test.cpp create mode 100644 auth0_flutter/windows/test/user_profile_test.cpp diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1d0798e6..5238c61b2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -306,6 +306,48 @@ jobs: name: Test results path: 'auth0_flutter/example/build/app/reports/androidTests/*.xml' + test-windows-unit: + name: Run native Windows unit tests + runs-on: windows-latest + environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} + + steps: + - name: Checkout + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + + - name: Install Flutter + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # pin@v2.21.0 + with: + flutter-version: ${{ env.flutter }} + channel: stable + cache: true + + - name: Add example/.env + working-directory: auth0_flutter + run: Copy-Item example/.env.example example/.env + shell: powershell + + - name: Build Windows example app + working-directory: auth0_flutter/example + run: flutter build windows --debug + + - name: Set up vcpkg + uses: lukka/run-vcpkg@beb6564f0aeb229060c2cdb32d04a1190e92f18f # pin@v11 + with: + vcpkgDirectory: '${{ github.workspace }}/vcpkg' + vcpkgGitCommitId: '7a26ed4ca1cc6eafc66666166f8f36c49dc5ec18' + + - name: Build Windows unit tests + working-directory: auth0_flutter/windows + run: | + cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake -DCMAKE_BUILD_TYPE=Debug + cmake --build build --config Debug + shell: cmd + + - name: Run Windows unit tests + working-directory: auth0_flutter/windows/build + run: ctest -C Debug --output-on-failure + # test-android-smoke: # name: Run native Android smoke tests using API-level ${{ matrix.android-api }} # runs-on: macos-latest-xl diff --git a/auth0_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/auth0_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index b891863c2..792fc0474 100644 --- a/auth0_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/auth0_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -81,6 +81,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt index b190cf13f..a4cc1c83b 100644 --- a/auth0_flutter/windows/CMakeLists.txt +++ b/auth0_flutter/windows/CMakeLists.txt @@ -106,6 +106,10 @@ if (AUTH0_FLUTTER_ENABLE_TESTS) add_executable(${TEST_RUNNER} test/jwt_util_test.cpp + test/time_util_test.cpp + test/token_decoder_test.cpp + test/user_identity_test.cpp + test/user_profile_test.cpp # Reuse plugin sources directly (NO flutter plugin entrypoints) auth0_client.cpp diff --git a/auth0_flutter/windows/test/time_util_test.cpp b/auth0_flutter/windows/test/time_util_test.cpp new file mode 100644 index 000000000..669580ca1 --- /dev/null +++ b/auth0_flutter/windows/test/time_util_test.cpp @@ -0,0 +1,141 @@ +#include + +#include "time_util.h" +#include + +/* ---------------- ParseIso8601 ---------------- */ + +TEST(ParseIso8601Test, ParsesValidIso8601String) { + std::string iso = "2023-12-25T10:30:45Z"; + auto result = ParseIso8601(iso); + + ASSERT_TRUE(result.has_value()); + + // Convert back to verify + std::string roundtrip = ToIso8601(result.value()); + EXPECT_EQ(roundtrip, iso); +} + +TEST(ParseIso8601Test, ParsesEpochTime) { + std::string iso = "1970-01-01T00:00:00Z"; + auto result = ParseIso8601(iso); + + ASSERT_TRUE(result.has_value()); + + std::time_t t = std::chrono::system_clock::to_time_t(result.value()); + EXPECT_EQ(t, 0); +} + +TEST(ParseIso8601Test, ReturnsNulloptForEmptyString) { + std::string iso = ""; + auto result = ParseIso8601(iso); + + EXPECT_FALSE(result.has_value()); +} + +TEST(ParseIso8601Test, ReturnsNulloptForInvalidFormat) { + std::string iso = "invalid-date"; + auto result = ParseIso8601(iso); + + EXPECT_FALSE(result.has_value()); +} + +TEST(ParseIso8601Test, ReturnsNulloptForPartialDate) { + std::string iso = "2023-12-25"; + auto result = ParseIso8601(iso); + + EXPECT_FALSE(result.has_value()); +} + +TEST(ParseIso8601Test, ParsesDifferentDatesCorrectly) { + std::string iso1 = "2020-01-15T08:00:00Z"; + std::string iso2 = "2025-06-30T23:59:59Z"; + + auto result1 = ParseIso8601(iso1); + auto result2 = ParseIso8601(iso2); + + ASSERT_TRUE(result1.has_value()); + ASSERT_TRUE(result2.has_value()); + + // Verify result2 is after result1 + EXPECT_TRUE(result2.value() > result1.value()); +} + +/* ---------------- ToIso8601 ---------------- */ + +TEST(ToIso8601Test, FormatsTimePointCorrectly) { + // Create a known time point + std::tm tm{}; + tm.tm_year = 2023 - 1900; // years since 1900 + tm.tm_mon = 11; // December (0-based) + tm.tm_mday = 25; + tm.tm_hour = 10; + tm.tm_min = 30; + tm.tm_sec = 45; + + #if defined(_WIN32) + std::time_t t = _mkgmtime(&tm); + #else + std::time_t t = timegm(&tm); + #endif + + auto tp = std::chrono::system_clock::from_time_t(t); + std::string result = ToIso8601(tp); + + EXPECT_EQ(result, "2023-12-25T10:30:45Z"); +} + +TEST(ToIso8601Test, FormatsEpochCorrectly) { + auto epoch = std::chrono::system_clock::from_time_t(0); + std::string result = ToIso8601(epoch); + + EXPECT_EQ(result, "1970-01-01T00:00:00Z"); +} + +TEST(ToIso8601Test, HandlesLeapYear) { + std::tm tm{}; + tm.tm_year = 2020 - 1900; // Leap year + tm.tm_mon = 1; // February + tm.tm_mday = 29; // 29th (valid in leap year) + tm.tm_hour = 12; + tm.tm_min = 0; + tm.tm_sec = 0; + + #if defined(_WIN32) + std::time_t t = _mkgmtime(&tm); + #else + std::time_t t = timegm(&tm); + #endif + + auto tp = std::chrono::system_clock::from_time_t(t); + std::string result = ToIso8601(tp); + + EXPECT_EQ(result, "2020-02-29T12:00:00Z"); +} + +/* ---------------- Round-trip tests ---------------- */ + +TEST(TimeUtilRoundTripTest, ParseAndFormatAreInverses) { + std::string original = "2024-07-15T14:30:00Z"; + + auto parsed = ParseIso8601(original); + ASSERT_TRUE(parsed.has_value()); + + std::string formatted = ToIso8601(parsed.value()); + EXPECT_EQ(formatted, original); +} + +TEST(TimeUtilRoundTripTest, FormatAndParseAreInverses) { + auto now = std::chrono::system_clock::now(); + + std::string formatted = ToIso8601(now); + auto parsed = ParseIso8601(formatted); + + ASSERT_TRUE(parsed.has_value()); + + // Time should match (within a second due to formatting precision) + auto diff = std::chrono::duration_cast( + now - parsed.value()).count(); + + EXPECT_LE(std::abs(diff), 1); +} diff --git a/auth0_flutter/windows/test/token_decoder_test.cpp b/auth0_flutter/windows/test/token_decoder_test.cpp new file mode 100644 index 000000000..279f69697 --- /dev/null +++ b/auth0_flutter/windows/test/token_decoder_test.cpp @@ -0,0 +1,203 @@ +#include + +#include "token_decoder.h" +#include +#include + +using web::json::value; + +/* ---------------- DecodeTokenResponse ---------------- */ + +TEST(DecodeTokenResponseTest, DecodesMinimalResponse) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + + Credentials creds = DecodeTokenResponse(json); + + EXPECT_EQ(creds.accessToken, "test_access_token"); + EXPECT_EQ(creds.tokenType, "Bearer"); + EXPECT_FALSE(creds.idToken.empty()); // Empty, not uninitialized + EXPECT_FALSE(creds.refreshToken.has_value()); + EXPECT_FALSE(creds.expiresIn.has_value()); + EXPECT_FALSE(creds.expiresAt.has_value()); + EXPECT_TRUE(creds.scope.empty()); +} + +TEST(DecodeTokenResponseTest, DecodesFullResponse) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("id_token")] = value::string(U("test_id_token")); + json[U("refresh_token")] = value::string(U("test_refresh_token")); + json[U("expires_in")] = value::number(3600); + json[U("scope")] = value::string(U("openid profile email")); + + Credentials creds = DecodeTokenResponse(json); + + EXPECT_EQ(creds.accessToken, "test_access_token"); + EXPECT_EQ(creds.tokenType, "Bearer"); + EXPECT_EQ(creds.idToken, "test_id_token"); + ASSERT_TRUE(creds.refreshToken.has_value()); + EXPECT_EQ(creds.refreshToken.value(), "test_refresh_token"); + ASSERT_TRUE(creds.expiresIn.has_value()); + EXPECT_EQ(creds.expiresIn.value(), 3600); + ASSERT_TRUE(creds.expiresAt.has_value()); // Should be computed from expires_in + + ASSERT_EQ(creds.scope.size(), 3u); + EXPECT_EQ(creds.scope[0], "openid"); + EXPECT_EQ(creds.scope[1], "profile"); + EXPECT_EQ(creds.scope[2], "email"); +} + +TEST(DecodeTokenResponseTest, ComputesExpiresAtFromExpiresIn) { + auto before = std::chrono::system_clock::now(); + + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("expires_in")] = value::number(7200); // 2 hours + + Credentials creds = DecodeTokenResponse(json); + + auto after = std::chrono::system_clock::now(); + + ASSERT_TRUE(creds.expiresIn.has_value()); + EXPECT_EQ(creds.expiresIn.value(), 7200); + ASSERT_TRUE(creds.expiresAt.has_value()); + + // Verify expiresAt is approximately now + 7200 seconds + auto expected = before + std::chrono::seconds(7200); + auto diff = std::chrono::duration_cast( + creds.expiresAt.value() - expected).count(); + + // Allow a small margin for test execution time + EXPECT_LE(std::abs(diff), 2); +} + +TEST(DecodeTokenResponseTest, UsesExplicitExpiresAt) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("expires_at")] = value::string(U("2025-12-31T23:59:59Z")); + + Credentials creds = DecodeTokenResponse(json); + + ASSERT_TRUE(creds.expiresAt.has_value()); + + // Convert back to verify + std::time_t t = std::chrono::system_clock::to_time_t(creds.expiresAt.value()); + std::tm tm{}; + + #if defined(_WIN32) + gmtime_s(&tm, &t); + #else + gmtime_r(&t, &tm); + #endif + + EXPECT_EQ(tm.tm_year + 1900, 2025); + EXPECT_EQ(tm.tm_mon + 1, 12); + EXPECT_EQ(tm.tm_mday, 31); +} + +TEST(DecodeTokenResponseTest, PrefersExpiresAtOverExpiresIn) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("expires_at")] = value::string(U("2025-12-31T23:59:59Z")); + json[U("expires_in")] = value::number(3600); // This should be ignored + + Credentials creds = DecodeTokenResponse(json); + + ASSERT_TRUE(creds.expiresAt.has_value()); + + // Verify it's the explicit date, not now + 3600 + std::time_t t = std::chrono::system_clock::to_time_t(creds.expiresAt.value()); + std::tm tm{}; + + #if defined(_WIN32) + gmtime_s(&tm, &t); + #else + gmtime_r(&t, &tm); + #endif + + EXPECT_EQ(tm.tm_year + 1900, 2025); +} + +TEST(DecodeTokenResponseTest, HandlesSingleScope) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("scope")] = value::string(U("openid")); + + Credentials creds = DecodeTokenResponse(json); + + ASSERT_EQ(creds.scope.size(), 1u); + EXPECT_EQ(creds.scope[0], "openid"); +} + +TEST(DecodeTokenResponseTest, HandlesEmptyScope) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("scope")] = value::string(U("")); + + Credentials creds = DecodeTokenResponse(json); + + EXPECT_TRUE(creds.scope.empty()); +} + +TEST(DecodeTokenResponseTest, HandlesScopeWithMultipleSpaces) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("scope")] = value::string(U("openid profile email")); + + Credentials creds = DecodeTokenResponse(json); + + // Multiple spaces should be handled by istringstream + ASSERT_EQ(creds.scope.size(), 3u); + EXPECT_EQ(creds.scope[0], "openid"); + EXPECT_EQ(creds.scope[1], "profile"); + EXPECT_EQ(creds.scope[2], "email"); +} + +TEST(DecodeTokenResponseTest, ThrowsOnMissingAccessToken) { + value json; + json[U("token_type")] = value::string(U("Bearer")); + + EXPECT_THROW(DecodeTokenResponse(json), web::json::json_exception); +} + +TEST(DecodeTokenResponseTest, ThrowsOnMissingTokenType) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + + EXPECT_THROW(DecodeTokenResponse(json), web::json::json_exception); +} + +TEST(DecodeTokenResponseTest, HandlesNonIntegerExpiresIn) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("expires_in")] = value::string(U("not_a_number")); + + // Should not throw, just skip expires_in + Credentials creds = DecodeTokenResponse(json); + + EXPECT_FALSE(creds.expiresIn.has_value()); + EXPECT_FALSE(creds.expiresAt.has_value()); +} + +TEST(DecodeTokenResponseTest, HandlesInvalidExpiresAtFormat) { + value json; + json[U("access_token")] = value::string(U("test_access_token")); + json[U("token_type")] = value::string(U("Bearer")); + json[U("expires_at")] = value::string(U("invalid-date")); + json[U("expires_in")] = value::number(3600); + + Credentials creds = DecodeTokenResponse(json); + + // Should fall back to computing from expires_in + ASSERT_TRUE(creds.expiresAt.has_value()); +} diff --git a/auth0_flutter/windows/test/user_identity_test.cpp b/auth0_flutter/windows/test/user_identity_test.cpp new file mode 100644 index 000000000..2fed229c5 --- /dev/null +++ b/auth0_flutter/windows/test/user_identity_test.cpp @@ -0,0 +1,255 @@ +#include + +#include "user_identity.h" +#include +#include + +using web::json::value; +using flutter::EncodableMap; +using flutter::EncodableValue; + +/* ---------------- FromJson ---------------- */ + +TEST(UserIdentityFromJsonTest, ParsesMinimalIdentity) { + value json; + json[U("user_id")] = value::string(U("auth0|123456")); + json[U("connection")] = value::string(U("Username-Password-Authentication")); + json[U("provider")] = value::string(U("auth0")); + + UserIdentity identity = UserIdentity::FromJson(json); + + EXPECT_EQ(identity.id, "auth0|123456"); + EXPECT_EQ(identity.connection, "Username-Password-Authentication"); + EXPECT_EQ(identity.provider, "auth0"); + EXPECT_FALSE(identity.isSocial); + EXPECT_FALSE(identity.accessToken.has_value()); + EXPECT_FALSE(identity.accessTokenSecret.has_value()); + EXPECT_TRUE(identity.profileInfo.empty()); +} + +TEST(UserIdentityFromJsonTest, ParsesFullIdentity) { + value json; + json[U("user_id")] = value::string(U("google-oauth2|123456")); + json[U("connection")] = value::string(U("google-oauth2")); + json[U("provider")] = value::string(U("google-oauth2")); + json[U("isSocial")] = value::boolean(true); + json[U("access_token")] = value::string(U("test_access_token")); + json[U("access_token_secret")] = value::string(U("test_secret")); + + value profileData; + profileData[U("email")] = value::string(U("user@example.com")); + profileData[U("name")] = value::string(U("John Doe")); + json[U("profileData")] = profileData; + + UserIdentity identity = UserIdentity::FromJson(json); + + EXPECT_EQ(identity.id, "google-oauth2|123456"); + EXPECT_EQ(identity.connection, "google-oauth2"); + EXPECT_EQ(identity.provider, "google-oauth2"); + EXPECT_TRUE(identity.isSocial); + ASSERT_TRUE(identity.accessToken.has_value()); + EXPECT_EQ(identity.accessToken.value(), "test_access_token"); + ASSERT_TRUE(identity.accessTokenSecret.has_value()); + EXPECT_EQ(identity.accessTokenSecret.value(), "test_secret"); + + EXPECT_EQ(identity.profileInfo.size(), 2u); + EXPECT_TRUE(identity.profileInfo.find(EncodableValue("email")) != identity.profileInfo.end()); + EXPECT_TRUE(identity.profileInfo.find(EncodableValue("name")) != identity.profileInfo.end()); +} + +TEST(UserIdentityFromJsonTest, HandlesSocialIdentityWithoutTokens) { + value json; + json[U("user_id")] = value::string(U("facebook|123456")); + json[U("connection")] = value::string(U("facebook")); + json[U("provider")] = value::string(U("facebook")); + json[U("isSocial")] = value::boolean(true); + + UserIdentity identity = UserIdentity::FromJson(json); + + EXPECT_EQ(identity.provider, "facebook"); + EXPECT_TRUE(identity.isSocial); + EXPECT_FALSE(identity.accessToken.has_value()); + EXPECT_FALSE(identity.accessTokenSecret.has_value()); +} + +TEST(UserIdentityFromJsonTest, HandlesEmptyProfileData) { + value json; + json[U("user_id")] = value::string(U("auth0|123456")); + json[U("connection")] = value::string(U("Username-Password-Authentication")); + json[U("provider")] = value::string(U("auth0")); + json[U("profileData")] = value::object(); + + UserIdentity identity = UserIdentity::FromJson(json); + + EXPECT_TRUE(identity.profileInfo.empty()); +} + +TEST(UserIdentityFromJsonTest, HandlesProfileDataWithVariousTypes) { + value json; + json[U("user_id")] = value::string(U("auth0|123456")); + json[U("connection")] = value::string(U("Username-Password-Authentication")); + json[U("provider")] = value::string(U("auth0")); + + value profileData; + profileData[U("string_field")] = value::string(U("text")); + profileData[U("number_field")] = value::number(42); + profileData[U("bool_field")] = value::boolean(true); + json[U("profileData")] = profileData; + + UserIdentity identity = UserIdentity::FromJson(json); + + EXPECT_EQ(identity.profileInfo.size(), 3u); + + auto stringIt = identity.profileInfo.find(EncodableValue("string_field")); + ASSERT_TRUE(stringIt != identity.profileInfo.end()); + EXPECT_TRUE(std::holds_alternative(stringIt->second)); + + auto numberIt = identity.profileInfo.find(EncodableValue("number_field")); + ASSERT_TRUE(numberIt != identity.profileInfo.end()); + EXPECT_TRUE(std::holds_alternative(numberIt->second)); + + auto boolIt = identity.profileInfo.find(EncodableValue("bool_field")); + ASSERT_TRUE(boolIt != identity.profileInfo.end()); + EXPECT_TRUE(std::holds_alternative(boolIt->second)); +} + +TEST(UserIdentityFromJsonTest, ThrowsOnMissingRequiredField) { + value json; + json[U("user_id")] = value::string(U("auth0|123456")); + json[U("connection")] = value::string(U("Username-Password-Authentication")); + // Missing provider + + EXPECT_THROW(UserIdentity::FromJson(json), web::json::json_exception); +} + +/* ---------------- FromEncodable ---------------- */ + +TEST(UserIdentityFromEncodableTest, ParsesProviderAndUserId) { + EncodableMap map; + map[EncodableValue("provider")] = EncodableValue("auth0"); + map[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + + UserIdentity identity = UserIdentity::FromEncodable(map); + + EXPECT_EQ(identity.provider, "auth0"); + EXPECT_EQ(identity.id, "auth0|123456"); +} + +TEST(UserIdentityFromEncodableTest, HandlesEmptyMap) { + EncodableMap map; + + UserIdentity identity = UserIdentity::FromEncodable(map); + + EXPECT_TRUE(identity.provider.empty()); + EXPECT_TRUE(identity.id.empty()); +} + +TEST(UserIdentityFromEncodableTest, HandlesOnlyProvider) { + EncodableMap map; + map[EncodableValue("provider")] = EncodableValue("google-oauth2"); + + UserIdentity identity = UserIdentity::FromEncodable(map); + + EXPECT_EQ(identity.provider, "google-oauth2"); + EXPECT_TRUE(identity.id.empty()); +} + +TEST(UserIdentityFromEncodableTest, HandlesOnlyUserId) { + EncodableMap map; + map[EncodableValue("user_id")] = EncodableValue("facebook|789"); + + UserIdentity identity = UserIdentity::FromEncodable(map); + + EXPECT_EQ(identity.id, "facebook|789"); + EXPECT_TRUE(identity.provider.empty()); +} + +TEST(UserIdentityFromEncodableTest, IgnoresNonStringValues) { + EncodableMap map; + map[EncodableValue("provider")] = EncodableValue(42); // Not a string + map[EncodableValue("user_id")] = EncodableValue(true); // Not a string + + UserIdentity identity = UserIdentity::FromEncodable(map); + + EXPECT_TRUE(identity.provider.empty()); + EXPECT_TRUE(identity.id.empty()); +} + +/* ---------------- ToEncodableMap ---------------- */ + +TEST(UserIdentityToEncodableMapTest, EncodesMinimalIdentity) { + UserIdentity identity; + identity.id = "auth0|123456"; + identity.connection = "Username-Password-Authentication"; + identity.provider = "auth0"; + identity.isSocial = false; + + EncodableMap map = identity.ToEncodableMap(); + + EXPECT_EQ(std::get(map.at(EncodableValue("id"))), "auth0|123456"); + EXPECT_EQ(std::get(map.at(EncodableValue("connection"))), + "Username-Password-Authentication"); + EXPECT_EQ(std::get(map.at(EncodableValue("provider"))), "auth0"); + EXPECT_EQ(std::get(map.at(EncodableValue("isSocial"))), false); + + // Optional fields should not be present + EXPECT_TRUE(map.find(EncodableValue("accessToken")) == map.end()); + EXPECT_TRUE(map.find(EncodableValue("accessTokenSecret")) == map.end()); +} + +TEST(UserIdentityToEncodableMapTest, EncodesFullIdentity) { + UserIdentity identity; + identity.id = "google-oauth2|123456"; + identity.connection = "google-oauth2"; + identity.provider = "google-oauth2"; + identity.isSocial = true; + identity.accessToken = "test_access_token"; + identity.accessTokenSecret = "test_secret"; + identity.profileInfo[EncodableValue("email")] = EncodableValue("user@example.com"); + + EncodableMap map = identity.ToEncodableMap(); + + EXPECT_EQ(std::get(map.at(EncodableValue("id"))), "google-oauth2|123456"); + EXPECT_EQ(std::get(map.at(EncodableValue("connection"))), "google-oauth2"); + EXPECT_EQ(std::get(map.at(EncodableValue("provider"))), "google-oauth2"); + EXPECT_EQ(std::get(map.at(EncodableValue("isSocial"))), true); + EXPECT_EQ(std::get(map.at(EncodableValue("accessToken"))), + "test_access_token"); + EXPECT_EQ(std::get(map.at(EncodableValue("accessTokenSecret"))), + "test_secret"); + + auto profileInfoIt = map.find(EncodableValue("profileInfo")); + ASSERT_TRUE(profileInfoIt != map.end()); + EXPECT_TRUE(std::holds_alternative(profileInfoIt->second)); +} + +TEST(UserIdentityToEncodableMapTest, HandlesEmptyProfileInfo) { + UserIdentity identity; + identity.id = "auth0|123456"; + identity.connection = "Username-Password-Authentication"; + identity.provider = "auth0"; + + EncodableMap map = identity.ToEncodableMap(); + + // Empty profileInfo should not be included + EXPECT_TRUE(map.find(EncodableValue("profileInfo")) == map.end()); +} + +/* ---------------- Round-trip tests ---------------- */ + +TEST(UserIdentityRoundTripTest, FromJsonToEncodableMapPreservesData) { + value json; + json[U("user_id")] = value::string(U("google-oauth2|123456")); + json[U("connection")] = value::string(U("google-oauth2")); + json[U("provider")] = value::string(U("google-oauth2")); + json[U("isSocial")] = value::boolean(true); + json[U("access_token")] = value::string(U("test_token")); + + UserIdentity identity = UserIdentity::FromJson(json); + EncodableMap map = identity.ToEncodableMap(); + + EXPECT_EQ(std::get(map.at(EncodableValue("id"))), "google-oauth2|123456"); + EXPECT_EQ(std::get(map.at(EncodableValue("provider"))), "google-oauth2"); + EXPECT_EQ(std::get(map.at(EncodableValue("isSocial"))), true); + EXPECT_EQ(std::get(map.at(EncodableValue("accessToken"))), "test_token"); +} diff --git a/auth0_flutter/windows/test/user_profile_test.cpp b/auth0_flutter/windows/test/user_profile_test.cpp new file mode 100644 index 000000000..8042b5504 --- /dev/null +++ b/auth0_flutter/windows/test/user_profile_test.cpp @@ -0,0 +1,313 @@ +#include + +#include "user_profile.h" +#include + +using flutter::EncodableMap; +using flutter::EncodableValue; +using flutter::EncodableList; + +/* ---------------- DeserializeUserProfile ---------------- */ + +TEST(DeserializeUserProfileTest, ParsesMinimalProfile) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + ASSERT_TRUE(profile.id.has_value()); + EXPECT_EQ(profile.id.value(), "auth0|123456"); + EXPECT_FALSE(profile.name.has_value()); + EXPECT_FALSE(profile.nickname.has_value()); + EXPECT_FALSE(profile.email.has_value()); + EXPECT_TRUE(profile.identities.empty()); +} + +TEST(DeserializeUserProfileTest, ParsesFullProfile) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + payload[EncodableValue("name")] = EncodableValue("John Doe"); + payload[EncodableValue("nickname")] = EncodableValue("johnd"); + payload[EncodableValue("picture")] = EncodableValue("https://example.com/pic.jpg"); + payload[EncodableValue("email")] = EncodableValue("john@example.com"); + payload[EncodableValue("email_verified")] = EncodableValue(true); + payload[EncodableValue("given_name")] = EncodableValue("John"); + payload[EncodableValue("family_name")] = EncodableValue("Doe"); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + ASSERT_TRUE(profile.id.has_value()); + EXPECT_EQ(profile.id.value(), "auth0|123456"); + ASSERT_TRUE(profile.name.has_value()); + EXPECT_EQ(profile.name.value(), "John Doe"); + ASSERT_TRUE(profile.nickname.has_value()); + EXPECT_EQ(profile.nickname.value(), "johnd"); + ASSERT_TRUE(profile.pictureURL.has_value()); + EXPECT_EQ(profile.pictureURL.value(), "https://example.com/pic.jpg"); + ASSERT_TRUE(profile.email.has_value()); + EXPECT_EQ(profile.email.value(), "john@example.com"); + ASSERT_TRUE(profile.isEmailVerified.has_value()); + EXPECT_EQ(profile.isEmailVerified.value(), true); + ASSERT_TRUE(profile.givenName.has_value()); + EXPECT_EQ(profile.givenName.value(), "John"); + ASSERT_TRUE(profile.familyName.has_value()); + EXPECT_EQ(profile.familyName.value(), "Doe"); +} + +TEST(DeserializeUserProfileTest, HandlesEmailVerifiedFalse) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + payload[EncodableValue("email")] = EncodableValue("john@example.com"); + payload[EncodableValue("email_verified")] = EncodableValue(false); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_FALSE(profile.isEmailVerified.value()); +} + +TEST(DeserializeUserProfileTest, HandlesIdentities) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + + EncodableList identities; + EncodableMap identity1; + identity1[EncodableValue("provider")] = EncodableValue("auth0"); + identity1[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + identities.push_back(EncodableValue(identity1)); + + EncodableMap identity2; + identity2[EncodableValue("provider")] = EncodableValue("google-oauth2"); + identity2[EncodableValue("user_id")] = EncodableValue("google-oauth2|789"); + identities.push_back(EncodableValue(identity2)); + + payload[EncodableValue("identities")] = EncodableValue(identities); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_EQ(profile.identities.size(), 2u); + EXPECT_EQ(profile.identities[0].provider, "auth0"); + EXPECT_EQ(profile.identities[1].provider, "google-oauth2"); +} + +TEST(DeserializeUserProfileTest, HandlesEmptyIdentities) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + payload[EncodableValue("identities")] = EncodableValue(EncodableList()); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_TRUE(profile.identities.empty()); +} + +TEST(DeserializeUserProfileTest, HandlesUserMetadata) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + + EncodableMap userMetadata; + userMetadata[EncodableValue("favorite_color")] = EncodableValue("blue"); + userMetadata[EncodableValue("hobby")] = EncodableValue("reading"); + payload[EncodableValue("user_metadata")] = EncodableValue(userMetadata); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_EQ(profile.userMetadata.size(), 2u); + EXPECT_EQ(std::get(profile.userMetadata.at(EncodableValue("favorite_color"))), + "blue"); + EXPECT_EQ(std::get(profile.userMetadata.at(EncodableValue("hobby"))), + "reading"); +} + +TEST(DeserializeUserProfileTest, HandlesAppMetadata) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + + EncodableMap appMetadata; + appMetadata[EncodableValue("roles")] = EncodableValue("admin"); + appMetadata[EncodableValue("plan")] = EncodableValue("premium"); + payload[EncodableValue("app_metadata")] = EncodableValue(appMetadata); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_EQ(profile.appMetadata.size(), 2u); + EXPECT_EQ(std::get(profile.appMetadata.at(EncodableValue("roles"))), + "admin"); + EXPECT_EQ(std::get(profile.appMetadata.at(EncodableValue("plan"))), + "premium"); +} + +TEST(DeserializeUserProfileTest, PreservesExtraInfo) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + payload[EncodableValue("custom_field")] = EncodableValue("custom_value"); + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_EQ(profile.extraInfo.size(), 2u); + EXPECT_TRUE(profile.extraInfo.find(EncodableValue("user_id")) != profile.extraInfo.end()); + EXPECT_TRUE(profile.extraInfo.find(EncodableValue("custom_field")) != profile.extraInfo.end()); +} + +TEST(DeserializeUserProfileTest, HandlesNonStringValues) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue(12345); // Not a string + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + EXPECT_FALSE(profile.id.has_value()); +} + +TEST(DeserializeUserProfileTest, HandlesNonBoolEmailVerified) { + EncodableMap payload; + payload[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + payload[EncodableValue("email_verified")] = EncodableValue("true"); // String, not bool + + UserProfile profile = UserProfile::DeserializeUserProfile(payload); + + // Should default to false + EXPECT_FALSE(profile.isEmailVerified.value()); +} + +/* ---------------- ToMap ---------------- */ + +TEST(UserProfileToMapTest, EncodesBasicFields) { + UserProfile profile; + profile.extraInfo[EncodableValue("sub")] = EncodableValue("auth0|123456"); + profile.extraInfo[EncodableValue("name")] = EncodableValue("John Doe"); + profile.extraInfo[EncodableValue("email")] = EncodableValue("john@example.com"); + profile.extraInfo[EncodableValue("email_verified")] = EncodableValue(true); + + EncodableMap map = profile.ToMap(); + + EXPECT_EQ(std::get(map.at(EncodableValue("sub"))), "auth0|123456"); + EXPECT_EQ(std::get(map.at(EncodableValue("name"))), "John Doe"); + EXPECT_EQ(std::get(map.at(EncodableValue("email"))), "john@example.com"); + EXPECT_EQ(std::get(map.at(EncodableValue("email_verified"))), true); +} + +TEST(UserProfileToMapTest, ExtractsCustomClaims) { + UserProfile profile; + profile.extraInfo[EncodableValue("sub")] = EncodableValue("auth0|123456"); + profile.extraInfo[EncodableValue("https://example.com/roles")] = EncodableValue("admin"); + profile.extraInfo[EncodableValue("https://example.com/plan")] = EncodableValue("premium"); + profile.extraInfo[EncodableValue("regular_field")] = EncodableValue("value"); + + EncodableMap map = profile.ToMap(); + + auto customClaimsIt = map.find(EncodableValue("custom_claims")); + ASSERT_TRUE(customClaimsIt != map.end()); + ASSERT_TRUE(std::holds_alternative(customClaimsIt->second)); + + const auto& customClaims = std::get(customClaimsIt->second); + EXPECT_EQ(customClaims.size(), 2u); + EXPECT_TRUE(customClaims.find(EncodableValue("https://example.com/roles")) != customClaims.end()); + EXPECT_TRUE(customClaims.find(EncodableValue("https://example.com/plan")) != customClaims.end()); + EXPECT_TRUE(customClaims.find(EncodableValue("regular_field")) == customClaims.end()); +} + +TEST(UserProfileToMapTest, HandlesEmptyCustomClaims) { + UserProfile profile; + profile.extraInfo[EncodableValue("sub")] = EncodableValue("auth0|123456"); + + EncodableMap map = profile.ToMap(); + + auto customClaimsIt = map.find(EncodableValue("custom_claims")); + ASSERT_TRUE(customClaimsIt != map.end()); + ASSERT_TRUE(std::holds_alternative(customClaimsIt->second)); + + const auto& customClaims = std::get(customClaimsIt->second); + EXPECT_TRUE(customClaims.empty()); +} + +TEST(UserProfileToMapTest, HandlesMissingFields) { + UserProfile profile; + profile.extraInfo[EncodableValue("sub")] = EncodableValue("auth0|123456"); + + EncodableMap map = profile.ToMap(); + + EXPECT_EQ(std::get(map.at(EncodableValue("sub"))), "auth0|123456"); + + // Other fields should exist but be empty/null + EXPECT_TRUE(map.find(EncodableValue("name")) != map.end()); + EXPECT_TRUE(map.find(EncodableValue("email")) != map.end()); +} + +/* ---------------- GetId ---------------- */ + +TEST(UserProfileGetIdTest, ReturnsIdWhenSet) { + UserProfile profile; + profile.id = "auth0|123456"; + + auto result = profile.GetId(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value(), "auth0|123456"); +} + +TEST(UserProfileGetIdTest, FallsBackToSubInExtraInfo) { + UserProfile profile; + profile.extraInfo[EncodableValue("sub")] = EncodableValue("auth0|789"); + + auto result = profile.GetId(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value(), "auth0|789"); +} + +TEST(UserProfileGetIdTest, PrefersIdOverSub) { + UserProfile profile; + profile.id = "auth0|123456"; + profile.extraInfo[EncodableValue("sub")] = EncodableValue("auth0|789"); + + auto result = profile.GetId(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value(), "auth0|123456"); +} + +TEST(UserProfileGetIdTest, ReturnsNulloptWhenNeitherSet) { + UserProfile profile; + + auto result = profile.GetId(); + + EXPECT_FALSE(result.has_value()); +} + +TEST(UserProfileGetIdTest, ReturnsNulloptWhenSubIsNotString) { + UserProfile profile; + profile.extraInfo[EncodableValue("sub")] = EncodableValue(12345); + + auto result = profile.GetId(); + + EXPECT_FALSE(result.has_value()); +} + +/* ---------------- Round-trip tests ---------------- */ + +TEST(UserProfileRoundTripTest, DeserializeAndToMapPreservesBasicData) { + EncodableMap original; + original[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + original[EncodableValue("name")] = EncodableValue("John Doe"); + original[EncodableValue("email")] = EncodableValue("john@example.com"); + + UserProfile profile = UserProfile::DeserializeUserProfile(original); + EncodableMap result = profile.ToMap(); + + // Note: user_id becomes sub in ToMap + EXPECT_TRUE(result.find(EncodableValue("name")) != result.end()); + EXPECT_TRUE(result.find(EncodableValue("email")) != result.end()); +} + +TEST(UserProfileRoundTripTest, PreservesCustomClaims) { + EncodableMap original; + original[EncodableValue("user_id")] = EncodableValue("auth0|123456"); + original[EncodableValue("https://example.com/roles")] = EncodableValue("admin"); + + UserProfile profile = UserProfile::DeserializeUserProfile(original); + EncodableMap result = profile.ToMap(); + + auto customClaimsIt = result.find(EncodableValue("custom_claims")); + ASSERT_TRUE(customClaimsIt != result.end()); + + const auto& customClaims = std::get(customClaimsIt->second); + EXPECT_TRUE(customClaims.find(EncodableValue("https://example.com/roles")) != customClaims.end()); +} From 052648ae53daa891e89102b55d0b889328a82b7a Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Tue, 20 Jan 2026 11:13:42 +0530 Subject: [PATCH 17/18] Resolves PR comments - adds uniform naming convention to files - percentage url encoding to login and logout urls - id token validation - presence of openid scope - code refactoring (cherry picked from commit 5f1b3085b56c351fd70b63674b961cef3f3b719d) --- .github/actions/setup-darwin/action.yml | 2 +- .github/workflows/main.yml | 183 +++-- .gitignore | 1 + .idea/.gitignore | 3 - .idea/auth0-flutter.iml | 32 - .idea/modules.xml | 8 - .idea/vcs.xml | 6 - .vscode/settings.json | 87 --- appium-test/package-lock.json | 6 +- auth0_flutter/.metadata | 35 +- auth0_flutter/CHANGELOG.md | 7 + auth0_flutter/EXAMPLES.md | 55 ++ auth0_flutter/README.md | 106 ++- auth0_flutter/android/build.gradle | 2 +- .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 4 +- .../CustomTokenExchangeApiRequestHandler.kt | 69 ++ .../GetCredentialsUserInfoRequestHandler.kt | 25 + .../web_auth/LogoutWebAuthRequestHandler.kt | 24 +- ...CredentialsManagerMethodCallHandlerTest.kt | 79 ++- .../LogoutWebAuthRequestHandlerTest.kt | 35 + ...ustomTokenExchangeApiRequestHandlerTest.kt | 300 ++++++++ ...etCredentialsUserInfoRequestHandlerTest.kt | 217 ++++++ ...hAPICustomTokenExchangeMethodHandler.swift | 48 ++ .../Classes/AuthAPI/AuthAPIHandler.swift | 2 + .../CredentialsManagerHandler.swift | 23 +- ...dentialsManagerUserInfoMethodHandler.swift | 20 + auth0_flutter/darwin/auth0_flutter.podspec | 4 +- .../example/android/app/build.gradle | 4 +- .../example/android/app/build.gradle.kts | 44 -- .../auth0_flutter_example/MainActivity.kt | 5 - .../src/main/res/values/strings.xml.example | 4 +- .../example/android/build.gradle.kts | 21 - .../example/android/settings.gradle.kts | 25 - .../plugin_integration_test.dart | 25 - .../xcshareddata/xcschemes/Runner.xcscheme | 1 - .../example/ios/RunnerTests/RunnerTests.swift | 27 - ...ustomTokenExchangeMethodHandlerTests.swift | 167 +++++ .../Tests/AuthAPI/AuthAPIHandlerTests.swift | 1 + .../CredentialsManagerHandlerTests.swift | 17 +- ...alsManagerUserInfoMethodHandlerTests.swift | 174 +++++ auth0_flutter/example/ios/Tests/Mocks.swift | 2 + auth0_flutter/example/lib/example_app.dart | 53 +- auth0_flutter/example/lib/main.dart | 2 +- .../macos/Runner/DebugProfile.entitlements | 12 - .../macos/RunnerTests/RunnerTests.swift | 28 - .../example/windows/runner/.vs/CMake Overview | 0 .../windows/runner/.vs/ProjectSettings.json | 3 - .../windows/runner/.vs/VSWorkspaceState.json | 12 - .../example/windows/runner/.vs/cmake.db | Bin 65536 -> 0 bytes ...4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx | Bin 56120 -> 0 bytes .../windows/runner/.vs/runner/v17/.wsuo | Bin 15872 -> 0 bytes .../runner/.vs/runner/v17/Browse.VC.db | Bin 614400 -> 0 bytes .../runner/.vs/runner/v17/DocumentLayout.json | 12 - .../example/windows/runner/.vs/slnx.sqlite | Bin 90112 -> 0 bytes ...hAPICustomTokenExchangeMethodHandler.swift | 1 + ...dentialsManagerUserInfoMethodHandler.swift | 1 + auth0_flutter/ios/auth0_flutter.podspec | 4 +- auth0_flutter/lib/auth0_flutter.dart | 23 + auth0_flutter/lib/auth0_flutter_web.dart | 95 +++ .../desktop/windows_web_authentication.dart | 221 ++++++ .../lib/src/mobile/authentication_api.dart | 53 ++ .../lib/src/mobile/credentials_manager.dart | 8 + .../lib/src/mobile/web_authentication.dart | 14 +- auth0_flutter/lib/src/version.dart | 2 +- .../src/web/auth0_flutter_plugin_real.dart | 14 + .../web/auth0_flutter_web_platform_proxy.dart | 3 + .../exchange_token_options_extension.dart | 17 + auth0_flutter/lib/src/web/js_interop.dart | 21 + ...hAPICustomTokenExchangeMethodHandler.swift | 1 + ...dentialsManagerUserInfoMethodHandler.swift | 1 + auth0_flutter/macos/auth0_flutter.podspec | 4 +- auth0_flutter/pubspec.yaml | 4 +- .../mobile/authentication_api_test.mocks.dart | 222 ++++-- .../test/mobile/credentials_manager_test.dart | 76 +- .../credentials_manager_test.mocks.dart | 89 ++- .../test/mobile/web_authentication_test.dart | 93 ++- .../mobile/web_authentication_test.mocks.dart | 188 +++-- .../test/web/auth0_flutter_web_test.dart | 154 ++++ .../web/auth0_flutter_web_test.mocks.dart | 17 + ...exchange_token_options_extension_test.dart | 71 ++ auth0_flutter/windows/.vs/CMake Overview | 0 .../windows/.vs/ProjectSettings.json | 3 - .../windows/.vs/VSWorkspaceState.json | 12 - auth0_flutter/windows/.vs/slnx.sqlite | Bin 90112 -> 0 bytes ...f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx | Bin 42428 -> 0 bytes auth0_flutter/windows/.vs/windows/v17/.wsuo | Bin 17920 -> 0 bytes .../windows/.vs/windows/v17/Browse.VC.db | Bin 565248 -> 0 bytes .../.vs/windows/v17/DocumentLayout.json | 12 - auth0_flutter/windows/CMakeLists.txt | 217 ++++-- auth0_flutter/windows/auth0_client.cpp | 20 +- .../windows/auth0_flutter_plugin.cpp | 658 ++---------------- auth0_flutter/windows/auth0_flutter_plugin.h | 53 +- ...0_flutter_web_auth_method_call_handler.cpp | 67 ++ ...th0_flutter_web_auth_method_call_handler.h | 85 +++ auth0_flutter/windows/authentication_error.h | 267 +++++++ auth0_flutter/windows/id_token_validator.cpp | 252 +++++++ auth0_flutter/windows/id_token_validator.h | 62 ++ auth0_flutter/windows/jwt_util.cpp | 47 +- auth0_flutter/windows/jwt_util.h | 10 +- auth0_flutter/windows/oauth_helpers.cpp | 212 ++++++ auth0_flutter/windows/oauth_helpers.h | 66 ++ .../web_auth/LoginWebAuthRequestHandler.cpp | 551 +++++++++++++++ .../login_web_auth_request_handler.cpp | 553 +++++++++++++++ .../web_auth/login_web_auth_request_handler.h | 67 ++ .../logout_web_auth_request_handler.cpp | 189 +++++ .../logout_web_auth_request_handler.h | 60 ++ .../web_auth/web_auth_request_handler.h | 59 ++ .../windows/test/id_token_validator_test.cpp | 542 +++++++++++++++ .../windows/test/oauth_helpers_test.cpp | 237 +++++++ auth0_flutter/windows/test/time_util_test.cpp | 6 +- auth0_flutter/windows/test/url_utils_test.cpp | 194 ++++++ .../windows/test/windows_utils_test.cpp | 128 ++++ auth0_flutter/windows/url_utils.cpp | 71 ++ auth0_flutter/windows/url_utils.h | 29 + auth0_flutter/windows/vcpkg.json | 15 +- auth0_flutter/windows/windows_utils.cpp | 61 ++ auth0_flutter/windows/windows_utils.h | 36 + auth0_flutter_platform_interface/CHANGELOG.md | 7 + .../lib/auth0_flutter_platform_interface.dart | 2 + .../auth_custom_token_exchange_options.dart | 26 + .../lib/src/auth0_flutter_auth_platform.dart | 6 + .../lib/src/auth0_flutter_web_platform.dart | 6 + .../credentials_manager_platform.dart | 25 +- .../method_channel_credentials_manager.dart | 27 +- .../method_channel_auth0_flutter_auth.dart | 13 + .../src/web-auth/web_auth_logout_options.dart | 7 +- .../lib/src/web/exchange_token_options.dart | 29 + auth0_flutter_platform_interface/pubspec.yaml | 2 +- ...th_custom_token_exchange_options_test.dart | 99 +++ ...ethod_channel_auth0_flutter_auth_test.dart | 153 ++++ ...d_channel_auth0_flutter_web_auth_test.dart | 22 +- ...thod_channel_credentials_manager_test.dart | 124 ++++ 132 files changed, 7379 insertions(+), 1429 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/auth0-flutter.iml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .vscode/settings.json create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt create mode 100644 auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift delete mode 100644 auth0_flutter/example/android/app/build.gradle.kts delete mode 100644 auth0_flutter/example/android/app/src/main/kotlin/com/example/auth0_flutter_example/MainActivity.kt delete mode 100644 auth0_flutter/example/android/build.gradle.kts delete mode 100644 auth0_flutter/example/android/settings.gradle.kts delete mode 100644 auth0_flutter/example/integration_test/plugin_integration_test.dart delete mode 100644 auth0_flutter/example/ios/RunnerTests/RunnerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerUserInfoMethodHandlerTests.swift delete mode 100644 auth0_flutter/example/macos/Runner/DebugProfile.entitlements delete mode 100644 auth0_flutter/example/macos/RunnerTests/RunnerTests.swift delete mode 100644 auth0_flutter/example/windows/runner/.vs/CMake Overview delete mode 100644 auth0_flutter/example/windows/runner/.vs/ProjectSettings.json delete mode 100644 auth0_flutter/example/windows/runner/.vs/VSWorkspaceState.json delete mode 100644 auth0_flutter/example/windows/runner/.vs/cmake.db delete mode 100644 auth0_flutter/example/windows/runner/.vs/runner/FileContentIndex/4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx delete mode 100644 auth0_flutter/example/windows/runner/.vs/runner/v17/.wsuo delete mode 100644 auth0_flutter/example/windows/runner/.vs/runner/v17/Browse.VC.db delete mode 100644 auth0_flutter/example/windows/runner/.vs/runner/v17/DocumentLayout.json delete mode 100644 auth0_flutter/example/windows/runner/.vs/slnx.sqlite create mode 120000 auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 120000 auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift create mode 100644 auth0_flutter/lib/src/desktop/windows_web_authentication.dart create mode 100644 auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart create mode 120000 auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift create mode 100644 auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart delete mode 100644 auth0_flutter/windows/.vs/CMake Overview delete mode 100644 auth0_flutter/windows/.vs/ProjectSettings.json delete mode 100644 auth0_flutter/windows/.vs/VSWorkspaceState.json delete mode 100644 auth0_flutter/windows/.vs/slnx.sqlite delete mode 100644 auth0_flutter/windows/.vs/windows/FileContentIndex/f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx delete mode 100644 auth0_flutter/windows/.vs/windows/v17/.wsuo delete mode 100644 auth0_flutter/windows/.vs/windows/v17/Browse.VC.db delete mode 100644 auth0_flutter/windows/.vs/windows/v17/DocumentLayout.json create mode 100644 auth0_flutter/windows/auth0_flutter_web_auth_method_call_handler.cpp create mode 100644 auth0_flutter/windows/auth0_flutter_web_auth_method_call_handler.h create mode 100644 auth0_flutter/windows/authentication_error.h create mode 100644 auth0_flutter/windows/id_token_validator.cpp create mode 100644 auth0_flutter/windows/id_token_validator.h create mode 100644 auth0_flutter/windows/oauth_helpers.cpp create mode 100644 auth0_flutter/windows/oauth_helpers.h create mode 100644 auth0_flutter/windows/request_handlers/web_auth/LoginWebAuthRequestHandler.cpp create mode 100644 auth0_flutter/windows/request_handlers/web_auth/login_web_auth_request_handler.cpp create mode 100644 auth0_flutter/windows/request_handlers/web_auth/login_web_auth_request_handler.h create mode 100644 auth0_flutter/windows/request_handlers/web_auth/logout_web_auth_request_handler.cpp create mode 100644 auth0_flutter/windows/request_handlers/web_auth/logout_web_auth_request_handler.h create mode 100644 auth0_flutter/windows/request_handlers/web_auth/web_auth_request_handler.h create mode 100644 auth0_flutter/windows/test/id_token_validator_test.cpp create mode 100644 auth0_flutter/windows/test/oauth_helpers_test.cpp create mode 100644 auth0_flutter/windows/test/url_utils_test.cpp create mode 100644 auth0_flutter/windows/test/windows_utils_test.cpp create mode 100644 auth0_flutter/windows/url_utils.cpp create mode 100644 auth0_flutter/windows/url_utils.h create mode 100644 auth0_flutter/windows/windows_utils.cpp create mode 100644 auth0_flutter/windows/windows_utils.h create mode 100644 auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart create mode 100644 auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index feb486e15..ebed9db76 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@b90be12699fdfcbee4440c2bba85f6f460446bb0 # pin@v1.279.0 + uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # pin@v1.288.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5238c61b2..80086cc27 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -60,7 +60,7 @@ jobs: - name: Analyze auth0_flutter_platform_interface package working-directory: auth0_flutter_platform_interface - run: flutter analyze --no-fatal-warnings --no-fatal-infos + run: flutter analyze test-auth0_flutter: name: Test auth0_flutter Flutter package @@ -126,7 +126,7 @@ jobs: strategy: matrix: xcode: - - '26.0' + - '26.2' env: platform: iOS @@ -160,41 +160,41 @@ jobs: with: name: iOS coverage path: auth0_flutter/example/ios/cobertura + # TODO: fix both android and iOS smoke testcases and uncomment them. + # test-ios-smoke: + # name: Run native iOS smoke tests using Xcode ${{ matrix.xcode }} + # runs-on: macos-15-large + # environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} - test-ios-smoke: - name: Run native iOS smoke tests using Xcode ${{ matrix.xcode }} - runs-on: macos-15-large - environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} - - env: - platform: iOS - USER_EMAIL: ${{ secrets.USER_EMAIL }} - USER_PASSWORD: ${{ secrets.USER_PASSWORD }} + # env: + # platform: iOS + # USER_EMAIL: ${{ secrets.USER_EMAIL }} + # USER_PASSWORD: ${{ secrets.USER_PASSWORD }} - strategy: - matrix: - xcode: - - '26.0' + # strategy: + # matrix: + # xcode: + # - '26.2' - steps: - - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # steps: + # - name: Checkout + # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - name: Set up environment - uses: ./.github/actions/setup-darwin - with: - platform: ${{ env.platform }} - ruby: ${{ env.ruby }} - flutter: ${{ env.flutter }} - xcode: ${{ matrix.xcode }} - auth0-domain: ${{ vars.AUTH0_DOMAIN }} - auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} + # - name: Set up environment + # uses: ./.github/actions/setup-darwin + # with: + # platform: ${{ env.platform }} + # ruby: ${{ env.ruby }} + # flutter: ${{ env.flutter }} + # xcode: ${{ matrix.xcode }} + # auth0-domain: ${{ vars.AUTH0_DOMAIN }} + # auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} - - name: Run iOS smoke tests - uses: ./.github/actions/smoke-tests-darwin - with: - platform: ${{ env.platform }} - destination: ${{ format('{0}{1}', 'platform=iOS Simulator,name=', env.ios-simulator) }} + # - name: Run iOS smoke tests + # uses: ./.github/actions/smoke-tests-darwin + # with: + # platform: ${{ env.platform }} + # destination: ${{ format('{0}{1}', 'platform=iOS Simulator,name=', env.ios-simulator) }} test-macos-unit: name: Run native macOS unit tests using Xcode ${{ matrix.xcode }} @@ -204,7 +204,7 @@ jobs: strategy: matrix: xcode: - - '26.0' + - '26.2' env: platform: macOS @@ -233,40 +233,41 @@ jobs: platform: ${{ env.platform }} destination: platform=macOS,arch=x86_64 - test-macos-smoke: - name: Run native macOS smoke tests using Xcode ${{ matrix.xcode }} - runs-on: macos-15 - environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} + # TODO: fix macos smoke tests + # test-macos-smoke: + # name: Run native macOS smoke tests using Xcode ${{ matrix.xcode }} + # runs-on: macos-15 + # environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} - env: - platform: macOS - USER_EMAIL: ${{ secrets.USER_EMAIL }} - USER_PASSWORD: ${{ secrets.USER_PASSWORD }} + # env: + # platform: macOS + # USER_EMAIL: ${{ secrets.USER_EMAIL }} + # USER_PASSWORD: ${{ secrets.USER_PASSWORD }} - strategy: - matrix: - xcode: - - '26.0' + # strategy: + # matrix: + # xcode: + # - '26.2' - steps: - - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # steps: + # - name: Checkout + # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - name: Set up environment - uses: ./.github/actions/setup-darwin - with: - platform: ${{ env.platform }} - ruby: ${{ env.ruby }} - flutter: ${{ env.flutter }} - xcode: ${{ matrix.xcode }} - auth0-domain: ${{ vars.AUTH0_DOMAIN }} - auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} + # - name: Set up environment + # uses: ./.github/actions/setup-darwin + # with: + # platform: ${{ env.platform }} + # ruby: ${{ env.ruby }} + # flutter: ${{ env.flutter }} + # xcode: ${{ matrix.xcode }} + # auth0-domain: ${{ vars.AUTH0_DOMAIN }} + # auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} - - name: Run macOS smoke tests - uses: ./.github/actions/smoke-tests-darwin - with: - platform: ${{ env.platform }} - destination: platform=macOS,arch=x86_64 + # - name: Run macOS smoke tests + # uses: ./.github/actions/smoke-tests-darwin + # with: + # platform: ${{ env.platform }} + # destination: platform=macOS,arch=x86_64 test-android-unit: name: Run native Android unit tests @@ -327,26 +328,50 @@ jobs: run: Copy-Item example/.env.example example/.env shell: powershell + - name: Set up vcpkg + uses: lukka/run-vcpkg@v11 # pin@v11 + with: + vcpkgDirectory: '${{ github.workspace }}/vcpkg' + vcpkgGitCommitId: '66c0373dc7fca549e5803087b9487edfe3aca0a1' + + - name: Install vcpkg dependencies + run: | + ${{ github.workspace }}\vcpkg\vcpkg install cpprestsdk:x64-windows openssl:x64-windows boost-system:x64-windows boost-date-time:x64-windows boost-regex:x64-windows + shell: cmd + - name: Build Windows example app working-directory: auth0_flutter/example run: flutter build windows --debug + env: + CMAKE_TOOLCHAIN_FILE: ${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake - - name: Set up vcpkg - uses: lukka/run-vcpkg@beb6564f0aeb229060c2cdb32d04a1190e92f18f # pin@v11 - with: - vcpkgDirectory: '${{ github.workspace }}/vcpkg' - vcpkgGitCommitId: '7a26ed4ca1cc6eafc66666166f8f36c49dc5ec18' + - name: Install OpenCppCoverage + run: choco install opencppcoverage + shell: powershell - name: Build Windows unit tests working-directory: auth0_flutter/windows run: | - cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake -DCMAKE_BUILD_TYPE=Debug + cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake -DAUTH0_FLUTTER_ENABLE_TESTS=ON -DCMAKE_BUILD_TYPE=Debug cmake --build build --config Debug shell: cmd - - name: Run Windows unit tests - working-directory: auth0_flutter/windows/build - run: ctest -C Debug --output-on-failure + - name: Run Windows unit tests with coverage + working-directory: auth0_flutter/windows + run: | + & "C:\Program Files\OpenCppCoverage\OpenCppCoverage.exe" ` + --sources ${{ github.workspace }}\auth0_flutter\windows ` + --excluded_sources ${{ github.workspace }}\auth0_flutter\windows\test ` + --export_type cobertura:coverage.xml ` + --export_type html:coverage_html ` + -- .\build\Debug\auth0_flutter_tests.exe + shell: powershell + + - name: Upload coverage report + uses: actions/upload-artifact@v6 + with: + name: Windows coverage + path: auth0_flutter/windows/coverage.xml # test-android-smoke: # name: Run native Android smoke tests using API-level ${{ matrix.android-api }} @@ -460,7 +485,8 @@ jobs: test-auth0_flutter, test-auth0_flutter_platform_interface, test-ios-unit, - test-android-unit + test-android-unit, + test-windows-unit ] steps: @@ -491,6 +517,12 @@ jobs: name: Android coverage path: coverage/android + - name: Download coverage report for Windows + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: Windows coverage + path: coverage/windows + - name: Upload coverage report for auth0_flutter uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de with: @@ -518,3 +550,10 @@ jobs: name: Auth0 Flutter flags: auth0_flutter_android directory: coverage/android + + - name: Upload coverage report for Windows + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de + with: + name: Auth0 Flutter + flags: auth0_flutter_windows + directory: coverage/windows diff --git a/.gitignore b/.gitignore index 93c50f1c7..2478e6f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Global coverage coverage/ +**/.vs/ appium-test/node_modules/* diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521a..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/auth0-flutter.iml b/.idea/auth0-flutter.iml deleted file mode 100644 index 72b8b8ab1..000000000 --- a/.idea/auth0-flutter.iml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 52637d39f..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b08d30f1a..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "files.associations": { - "variant": "cpp", - "tuple": "cpp", - "utility": "cpp", - "array": "cpp", - "vector": "cpp", - "xstring": "cpp", - "xutility": "cpp", - "algorithm": "cpp", - "any": "cpp", - "atomic": "cpp", - "bit": "cpp", - "bitset": "cpp", - "chrono": "cpp", - "cmath": "cpp", - "compare": "cpp", - "complex": "cpp", - "concepts": "cpp", - "deque": "cpp", - "exception": "cpp", - "format": "cpp", - "forward_list": "cpp", - "fstream": "cpp", - "functional": "cpp", - "future": "cpp", - "iosfwd": "cpp", - "istream": "cpp", - "iterator": "cpp", - "limits": "cpp", - "list": "cpp", - "map": "cpp", - "memory": "cpp", - "new": "cpp", - "numeric": "cpp", - "optional": "cpp", - "queue": "cpp", - "random": "cpp", - "ratio": "cpp", - "regex": "cpp", - "string": "cpp", - "system_error": "cpp", - "type_traits": "cpp", - "unordered_map": "cpp", - "xlocale": "cpp", - "xlocnum": "cpp", - "xmemory": "cpp", - "xtr1common": "cpp", - "xtree": "cpp", - "cctype": "cpp", - "charconv": "cpp", - "clocale": "cpp", - "codecvt": "cpp", - "condition_variable": "cpp", - "csetjmp": "cpp", - "cstddef": "cpp", - "cstdint": "cpp", - "cstdio": "cpp", - "cstdlib": "cpp", - "cstring": "cpp", - "ctime": "cpp", - "cwchar": "cpp", - "initializer_list": "cpp", - "iomanip": "cpp", - "ios": "cpp", - "iostream": "cpp", - "locale": "cpp", - "mutex": "cpp", - "ostream": "cpp", - "set": "cpp", - "sstream": "cpp", - "stdexcept": "cpp", - "stop_token": "cpp", - "streambuf": "cpp", - "thread": "cpp", - "typeinfo": "cpp", - "unordered_set": "cpp", - "xfacet": "cpp", - "xhash": "cpp", - "xiosbase": "cpp", - "xlocbuf": "cpp", - "xlocinfo": "cpp", - "xlocmes": "cpp", - "xlocmon": "cpp", - "xloctime": "cpp" - } -} \ No newline at end of file diff --git a/appium-test/package-lock.json b/appium-test/package-lock.json index 6f3b7c0b1..dd8aa575f 100644 --- a/appium-test/package-lock.json +++ b/appium-test/package-lock.json @@ -1751,9 +1751,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, diff --git a/auth0_flutter/.metadata b/auth0_flutter/.metadata index 5a30c6421..7f5726441 100644 --- a/auth0_flutter/.metadata +++ b/auth0_flutter/.metadata @@ -4,39 +4,8 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ea121f8859e4b13e47a8f845e4586164519588bc" - channel: "[user-branch]" + revision: 097d3313d8e2c7f901932d63e537c1acefb87800 + channel: stable project_type: plugin -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: android - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: ios - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: macos - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: web - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: windows - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/auth0_flutter/CHANGELOG.md b/auth0_flutter/CHANGELOG.md index 7a7e10b43..a9b0c67fc 100644 --- a/auth0_flutter/CHANGELOG.md +++ b/auth0_flutter/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [af-v2.0.0-beta.2](https://github.com/auth0/auth0-flutter/tree/af-v2.0.0-beta.2) (2026-01-22) +[Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v2.0.0-beta.1...af-v2.0.0-beta.2) + +**Added** +- feat: add custom token exchange support across all platforms [\#721](https://github.com/auth0/auth0-flutter/pull/721) ([sanchitmehta94](https://github.com/sanchitmehta94)) +- feat: Add allowedBrowsers parameter to logout API [\#726](https://github.com/auth0/auth0-flutter/pull/726) ([pmathew92](https://github.com/pmathew92)) + ## [af-v2.0.0-beta.1](https://github.com/auth0/auth0-flutter/tree/af-v2.0.0-beta.1) (2025-12-10) [Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v1.14.0...af-v2.0.0-beta.1) diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index c15c1e187..f4447c4d7 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -15,6 +15,7 @@ - [📱 Credentials Manager](#-credentials-manager) - [Check for stored credentials](#check-for-stored-credentials) - [Retrieve stored credentials](#retrieve-stored-credentials) + - [Retrieve user profile](#retrieve-user-profile) - [Custom implementations](#custom-implementations) - [Local authentication](#local-authentication) - [Credentials Manager configuration](#credentials-manager-configuration) @@ -29,6 +30,7 @@ - [Passwordless Login](#passwordless-login) - [Retrieve user information](#retrieve-user-information) - [Renew credentials](#renew-credentials) + - [Custom Token Exchange](#custom-token-exchange) - [Errors](#errors-2) - [🌐📱 Organizations](#-organizations) - [Log in to an organization](#log-in-to-an-organization) @@ -394,6 +396,7 @@ await webAuth.logout(); - [Check for stored credentials](#check-for-stored-credentials) - [Retrieve stored credentials](#retrieve-stored-credentials) +- [Retrieve user profile](#retrieve-user-profile) - [Custom implementations](#custom-implementations) - [Local authentication](#local-authentication) - [Credentials Manager configuration](#credentials-manager-configuration) @@ -428,6 +431,18 @@ final credentials = await auth0.credentialsManager.credentials(); > 💡 You do not need to call `credentialsManager.storeCredentials()` afterward. The Credentials Manager automatically persists the renewed credentials. +### Retrieve user profile + +Fetch the user profile associated with the stored credentials. This method returns `null` if no credentials are present in storage. + +```dart +final userProfile = await auth0.credentialsManager.user(); + +if (userProfile != null) { + print('Email: ${userProfile.email}'); +} +``` + ### Custom implementations flutter_auth0 exposes a built-in, default Credentials Manager implementation through the `credentialsManager` property. You can pass your own implementation to the `Auth0` constructor. If you're using Web Auth, this implementation will be used to store the user's credentials after login and delete them after logout. @@ -700,6 +715,46 @@ final didStore = > 💡 To obtain a refresh token, make sure your Auth0 application has the **refresh token** [grant enabled](https://auth0.com/docs/get-started/applications/update-grant-types). If you are also specifying an audience value, make sure that the corresponding Auth0 API has the **Allow Offline Access** [setting enabled](https://auth0.com/docs/get-started/apis/api-settings#access-settings). +### Custom Token Exchange + +[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to enable applications to exchange their existing tokens for Auth0 tokens when calling the /oauth/token endpoint. This is useful for advanced integration use cases, such as: +- Integrate an external identity provider +- Migrate to Auth0 + +> **Note:** This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to enable it for your tenant. + +
    + Mobile (Android/iOS) + +```dart +final credentials = await auth0.api.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional, defaults to {'openid', 'profile', 'email'} + organization: 'org_abc123', // Optional +); +``` + +
    + +
    + Web + +```dart +final credentials = await auth0Web.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional + organizationId: 'org_abc123', // Optional +); +``` + +
    + +> 💡 For more information, see the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) and [RFC 8693](https://tools.ietf.org/html/rfc8693). + ### Errors The Authentication API client will only throw `ApiException` exceptions. You can find more information in the `details` property of the exception. Check the [API documentation](https://pub.dev/documentation/auth0_flutter_platform_interface/latest/auth0_flutter_platform_interface/ApiException-class.html) to learn more about the available `ApiException` properties. diff --git a/auth0_flutter/README.md b/auth0_flutter/README.md index d2f8c45e1..fd188b4db 100644 --- a/auth0_flutter/README.md +++ b/auth0_flutter/README.md @@ -28,11 +28,11 @@ We're excited to announce the release of auth0_flutter v2.0.0! ### Requirements -| Flutter | Android | iOS | macOS | -| :---------- | :-------------- | :---------------- | :---------------- | -| SDK 3.24.0+ | Android API 21+ | iOS 14+ | macOS 11+ | -| Dart 3.5.0+ | Java 8+ | Swift 5.9+ | Swift 5.9+ | -| | | Xcode 15.x / 16.x | Xcode 15.x / 16.x | +| Flutter | Android | iOS | macOS | Windows | +| :---------- | :-------------- | :---------------- | :---------------- | :------------------------------- | +| SDK 3.24.0+ | Android API 21+ | iOS 14+ | macOS 11+ | Windows 10+ | +| Dart 3.5.0+ | Java 8+ | Swift 5.9+ | Swift 5.9+ | C++ 17, Visual Studio 2022 | +| | | Xcode 15.x / 16.x | Xcode 15.x / 16.x | vcpkg (for dependencies) | ### Installation @@ -77,6 +77,7 @@ Under the **Application URIs** section of the **Settings** page, configure the f - Android: `SCHEME://YOUR_DOMAIN/android/YOUR_PACKAGE_NAME/callback` - iOS: `https://YOUR_DOMAIN/ios/YOUR_BUNDLE_ID/callback,YOUR_BUNDLE_ID://YOUR_DOMAIN/ios/YOUR_BUNDLE_ID/callback` - macOS: `https://YOUR_DOMAIN/macos/YOUR_BUNDLE_ID/callback,YOUR_BUNDLE_ID://YOUR_DOMAIN/macos/YOUR_BUNDLE_ID/callback` +- Windows: `https://YOUR_HOSTED_DOMAIN/callback` (or your custom callback URL on your intermediary server)
    Example @@ -86,11 +87,37 @@ If your Auth0 domain was `company.us.auth0.com` and your package name (Android) - Android: `https://company.us.auth0.com/android/com.company.myapp/callback` - iOS: `https://company.us.auth0.com/ios/com.company.myapp/callback,com.company.myapp://company.us.auth0.com/ios/com.company.myapp/callback` - macOS: `https://company.us.auth0.com/macos/com.company.myapp/callback,com.company.myapp://company.us.auth0.com/macos/com.company.myapp/callback` +- Windows: `https://your-app.example.com/callback` (your intermediary server endpoint)
    +> 💡 **Windows**: The Windows implementation uses a custom scheme callback architecture (`auth0flutter://callback`). This requires an intermediary server to receive the Auth0 callback and forward it to your Windows app via the custom protocol. The intermediary server URL (e.g., `https://your-app.example.com/callback`) should be configured as the callback URL in your Auth0 dashboard. The server should handle the Auth0 redirect and trigger the `auth0flutter://` protocol to activate your app with the authorization code and state parameters. + Take note of the **client ID** and **domain** values under the **Basic Information** section. You'll need these values in the next step. +##### Security Considerations for Custom URL Schemes + +> ⚠️ **Important Security Information** +> +> Custom URL schemes (nonverifiable callback URIs) can be vulnerable to **app impersonation attacks**, where malicious apps could potentially intercept OAuth authorization codes by registering the same custom scheme on a device. +> +> **Recommended Best Practices:** +> +> - **Use HTTPS-based schemes whenever possible:** +> - iOS 17.4+ / macOS 14.4+: Use Universal Links +> - Android: Use Android App Links with HTTPS schemes +> - These verifiable schemes cryptographically bind your app to your domain, preventing impersonation +> +> - **If you must use custom URL schemes:** +> - Implement additional security measures such as PKCE (Proof Key for Code Exchange), which is automatically enabled in this SDK +> - Consider using short-lived authorization codes +> - Implement additional client-side validation +> - Be aware that custom schemes offer no protection against malicious apps on the same device +> +> - **For Windows applications:** Custom schemes are currently required due to platform limitations. Ensure your intermediary server validates requests and uses secure communication +> +> 📖 For more details about app impersonation risks and mitigation strategies, see [Auth0's Security Guidance: Measures Against App Impersonation](https://auth0.com/docs/secure/security-guidance/measures-against-app-impersonation) + #### 🌐 Web Head to the [Auth0 Dashboard](https://manage.auth0.com/#/applications/) and create a new **Single Page** application. @@ -127,7 +154,7 @@ Take note of the **client ID** and **domain** values under the **Basic Informati ### Configure the SDK -#### 📱 Mobile/macOS +#### 📱 Mobile/macOS/Windows Start by importing `auth0_flutter/auth0_flutter.dart`. @@ -247,6 +274,46 @@ If you have a [custom domain](https://auth0.com/docs/customize/custom-domains), > ⚠️ For the associated domain to work, your app must be signed with your team certificate **even when building for the iOS simulator**. Make sure you are using the Apple Team whose Team ID is configured in the **Settings** page of your application. +##### Windows: Configure protocol handler and intermediary server + +Windows authentication requires two components: + +1. **Custom Protocol Handler**: Your Windows app needs to register the `auth0flutter://` protocol handler +2. **Intermediary Server**: A hosted server that receives the Auth0 callback and forwards it to your app + +**Step 1: Register the custom protocol handler** + +The `auth0flutter://` protocol should be automatically registered when your app is installed. The Flutter Windows plugin handles protocol activation through the `PLUGIN_STARTUP_URL` environment variable. + +**Step 2: Set up your intermediary server** + +Create a web endpoint (e.g., `https://your-app.example.com/callback`) that: +1. Receives the Auth0 callback with `code` and `state` parameters +2. Redirects to `auth0flutter://callback?code=...&state=...` + +Example server implementation: + +```javascript +// Node.js/Express example +app.get('/callback', (req, res) => { + const { code, state, error, error_description } = req.query; + + if (error) { + res.redirect(`auth0flutter://callback?error=${error}&error_description=${error_description}`); + } else { + res.redirect(`auth0flutter://callback?code=${code}&state=${state}`); + } +}); +``` + +**Step 3: Configure Auth0 callback URLs** + +In your Auth0 application settings, add your intermediary server URL: +- **Allowed Callback URLs**: `https://your-app.example.com/callback` +- **Allowed Logout URLs**: `https://your-app.example.com/logout` + +The SDK will automatically use the `auth0flutter://callback` custom scheme internally to receive the forwarded callback from your server. + #### 🌐 Web Start by importing `auth0_flutter/auth0_flutter_web.dart`. @@ -271,24 +338,26 @@ Finally, in your `index.html` add the following `