diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..b08d30f1a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,87 @@ +{ + "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/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/example/.env.example b/auth0_flutter/example/.env.example index 99a2d408b..894cc348f 100644 --- a/auth0_flutter/example/.env.example +++ b/auth0_flutter/example/.env.example @@ -14,4 +14,4 @@ AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID # 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 +AUTH0_CUSTOM_SCHEME=YOUR_AUTH0_CUSTOM_SCHEME \ No newline at end of file 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..6494be7c4 --- /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', (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 + // 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/lib/example_app.dart b/auth0_flutter/example/lib/example_app.dart index a27f8d8f7..836293816 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,16 +28,16 @@ class _ExampleAppState extends State { void initState() { super.initState(); - auth0 = Auth0(dotenv.env['AUTH0_DOMAIN']!, dotenv.env['AUTH0_CLIENT_ID']!); - auth0Web = - Auth0Web(dotenv.env['AUTH0_DOMAIN']!, dotenv.env['AUTH0_CLIENT_ID']!); - webAuth = - auth0.webAuthentication(scheme: dotenv.env['AUTH0_CUSTOM_SCHEME']); + auth0 = Auth0(domain, clientId); + auth0Web = Auth0Web(domain, clientId); + webAuth = auth0.webAuthentication(scheme: 'https'); if (kIsWeb) { - auth0Web.onLoad().then((final credentials) => setState(() { - _output = credentials?.idToken ?? ''; - _isLoggedIn = credentials != null; - })); + auth0Web.onLoad().then( + (final credentials) => setState(() { + _output = credentials?.idToken ?? ''; + _isLoggedIn = credentials != null; + }), + ); } } @@ -101,15 +102,18 @@ class _ExampleAppState extends State { } Future apiLogin( - final String usernameOrEmail, final String password) async { + final String usernameOrEmail, + final String password, + ) async { String output; // Platform messages may fail, so we use a try/catch PlatformException. // We also handle the message potentially returning null. try { final result = await auth0.api.login( - usernameOrEmail: usernameOrEmail, - password: password, - connectionOrRealm: 'Username-Password-Authentication'); + usernameOrEmail: usernameOrEmail, + password: password, + connectionOrRealm: 'Username-Password-Authentication', + ); output = result.accessToken; } on ApiException catch (e) { output = e.toString(); @@ -151,7 +155,8 @@ class _ExampleAppState extends State { _isLoggedIn = true; }); - output = 'DPoP Login Successful!\n\n' + 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' @@ -172,7 +177,8 @@ class _ExampleAppState extends State { _isLoggedIn = true; }); - output = 'DPoP Login Successful!\n\n' + 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)}...'; @@ -192,46 +198,61 @@ class _ExampleAppState extends State { Widget build(final BuildContext context) { return MaterialApp( home: Scaffold( - appBar: AppBar(title: const Text('Auth0 Example')), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( + appBar: AppBar(title: const Text('Auth0 Example')), + body: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( padding: const EdgeInsets.all(padding), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ApiCard(action: apiLogin), - if (_isLoggedIn) - WebAuthCard( - label: 'Web Auth Logout', action: webAuthLogout) - 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), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ApiCard(action: apiLogin), + if (_isLoggedIn) + WebAuthCard( + label: 'Web Auth Logout', + action: webAuthLogout, + ) + 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, ), ), - ]), - )), - SliverFillRemaining( - child: Padding( - padding: const EdgeInsets.fromLTRB( - padding, 0.0, padding, padding), - child: Center(child: Text(_output)))), - ], - )), + onPressed: dpopLogin, + child: const Text( + 'DPoP Login', + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ), + SliverFillRemaining( + child: Padding( + padding: const EdgeInsets.fromLTRB( + padding, + 0.0, + padding, + padding, + ), + child: Center(child: Text(_output)), + ), + ), + ], + ), + ), ); } } 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/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/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/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 000000000..442fd4033 Binary files /dev/null and b/auth0_flutter/example/windows/runner/.vs/cmake.db differ 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 000000000..a304a758c Binary files /dev/null and b/auth0_flutter/example/windows/runner/.vs/runner/FileContentIndex/4c9b582b-a146-48e4-aa6e-e983816e5e8e.vsidx differ diff --git a/auth0_flutter/example/windows/runner/.vs/runner/v17/.wsuo b/auth0_flutter/example/windows/runner/.vs/runner/v17/.wsuo new file mode 100644 index 000000000..811c83a78 Binary files /dev/null and b/auth0_flutter/example/windows/runner/.vs/runner/v17/.wsuo differ diff --git a/auth0_flutter/example/windows/runner/.vs/runner/v17/Browse.VC.db b/auth0_flutter/example/windows/runner/.vs/runner/v17/Browse.VC.db new file mode 100644 index 000000000..087c3dd95 Binary files /dev/null and b/auth0_flutter/example/windows/runner/.vs/runner/v17/Browse.VC.db differ diff --git a/auth0_flutter/example/windows/runner/.vs/runner/v17/DocumentLayout.json b/auth0_flutter/example/windows/runner/.vs/runner/v17/DocumentLayout.json new file mode 100644 index 000000000..4781bc392 --- /dev/null +++ b/auth0_flutter/example/windows/runner/.vs/runner/v17/DocumentLayout.json @@ -0,0 +1,12 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\Administrator\\Documents\\auth0-flutter\\auth0_flutter\\example\\windows\\runner\\", + "Documents": [], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [] + } + ] +} \ No newline at end of file diff --git a/auth0_flutter/example/windows/runner/.vs/slnx.sqlite b/auth0_flutter/example/windows/runner/.vs/slnx.sqlite new file mode 100644 index 000000000..c06abf4a1 Binary files /dev/null and b/auth0_flutter/example/windows/runner/.vs/slnx.sqlite differ 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; } diff --git a/auth0_flutter/example/windows/runner/main.cpp b/auth0_flutter/example/windows/runner/main.cpp index a61bf80d3..41fb2fb62 100644 --- a/auth0_flutter/example/windows/runner/main.cpp +++ b/auth0_flutter/example/windows/runner/main.cpp @@ -1,27 +1,130 @@ #include #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); + // ----------------------------- + // 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 + // ----------------------------- + 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 + // ----------------------------- + 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(); - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); diff --git a/auth0_flutter/lib/src/mobile/web_authentication.dart b/auth0_flutter/lib/src/mobile/web_authentication.dart index 349ccd6d2..e030dc074 100644 --- a/auth0_flutter/lib/src/mobile/web_authentication.dart +++ b/auth0_flutter/lib/src/mobile/web_authentication.dart @@ -26,7 +26,11 @@ class WebAuthentication { final CredentialsManager? _credentialsManager; WebAuthentication( - this._account, this._userAgent, this._scheme, this._credentialsManager); + this._account, + this._userAgent, + this._scheme, + this._credentialsManager, + ); /// Redirects the user to the [Auth0 Universal Login page](https://auth0.com/docs/authenticate/login/auth0-universal-login) for authentication. If successful, it returns /// a set of tokens, as well as the user's profile (constructed from ID token @@ -79,40 +83,45 @@ class WebAuthentication { /// 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 { - 'openid', - 'profile', - 'email', - 'offline_access' - }, - final String? redirectUrl, - final String? organizationId, - final String? invitationUrl, - final bool useHTTPS = false, - final List allowedBrowsers = const [], - final bool useEphemeralSession = false, - final Map parameters = const {}, - final IdTokenValidationConfig idTokenValidationConfig = - const IdTokenValidationConfig(), - final SafariViewController? safariViewController, - final bool useDPoP = false}) async { + Future login({ + final String? audience, + final Set scopes = const { + 'openid', + 'profile', + 'email', + 'offline_access', + }, + final String? redirectUrl, + final String? organizationId, + final String? invitationUrl, + final bool useHTTPS = false, + final List allowedBrowsers = const [], + final bool useEphemeralSession = false, + final Map parameters = const {}, + final IdTokenValidationConfig idTokenValidationConfig = + const IdTokenValidationConfig(), + final SafariViewController? safariViewController, + final bool useDPoP = false, + }) async { final credentials = await Auth0FlutterWebAuthPlatform.instance.login( - _createWebAuthRequest(WebAuthLoginOptions( - audience: audience, - scopes: scopes, - redirectUrl: redirectUrl, - organizationId: organizationId, - invitationUrl: invitationUrl, - parameters: parameters, - idTokenValidationConfig: idTokenValidationConfig, - scheme: _scheme, - useHTTPS: useHTTPS, - useEphemeralSession: useEphemeralSession, - safariViewController: safariViewController, - allowedBrowsers: allowedBrowsers, - useDPoP: useDPoP))); + _createWebAuthRequest( + WebAuthLoginOptions( + audience: audience, + scopes: scopes, + redirectUrl: redirectUrl, + organizationId: organizationId, + invitationUrl: invitationUrl, + parameters: parameters, + idTokenValidationConfig: idTokenValidationConfig, + scheme: _scheme, + useHTTPS: useHTTPS, + useEphemeralSession: useEphemeralSession, + safariViewController: safariViewController, + allowedBrowsers: allowedBrowsers, + useDPoP: useDPoP, + ), + ), + ); await _credentialsManager?.storeCredentials(credentials); @@ -135,17 +144,21 @@ class WebAuthentication { /// versions of iOS and macOS. Requires an Associated Domain configured with /// the `webcredentials` service type, set to your Auth0 domain –or custom /// domain, if you have one. - Future logout( - {final String? returnTo, - final bool useHTTPS = false, - final bool federated = false}) async { - await Auth0FlutterWebAuthPlatform.instance.logout(_createWebAuthRequest( - WebAuthLogoutOptions( + Future logout({ + final String? returnTo, + final bool useHTTPS = false, + final bool federated = false, + }) async { + await Auth0FlutterWebAuthPlatform.instance.logout( + _createWebAuthRequest( + WebAuthLogoutOptions( returnTo: returnTo, scheme: _scheme, useHTTPS: useHTTPS, - federated: federated), - )); + federated: federated, + ), + ), + ); await _credentialsManager?.clearCredentials(); } @@ -157,9 +170,11 @@ class WebAuthentication { Auth0FlutterWebAuthPlatform.instance.cancel(); } - WebAuthRequest - _createWebAuthRequest( - final TOptions options) => - WebAuthRequest( - account: _account, options: options, userAgent: _userAgent); + WebAuthRequest _createWebAuthRequest< + TOptions extends RequestOptions + >(final TOptions options) => WebAuthRequest( + account: _account, + options: options, + userAgent: _userAgent, + ); } diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index 19be56d89..6bfa4de8e 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: Auth0FlutterPluginCApi # To add assets to your plugin package, add an assets section, like this: # assets: 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/.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 000000000..5e3038ecd Binary files /dev/null and b/auth0_flutter/windows/.vs/slnx.sqlite differ 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 000000000..27bc5077f Binary files /dev/null and b/auth0_flutter/windows/.vs/windows/FileContentIndex/f80ae7cd-876d-45a7-b522-5aa5be927f84.vsidx differ diff --git a/auth0_flutter/windows/.vs/windows/v17/.wsuo b/auth0_flutter/windows/.vs/windows/v17/.wsuo new file mode 100644 index 000000000..8288cb9ba Binary files /dev/null and b/auth0_flutter/windows/.vs/windows/v17/.wsuo differ diff --git a/auth0_flutter/windows/.vs/windows/v17/Browse.VC.db b/auth0_flutter/windows/.vs/windows/v17/Browse.VC.db new file mode 100644 index 000000000..6fbf1a7b3 Binary files /dev/null and b/auth0_flutter/windows/.vs/windows/v17/Browse.VC.db differ 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 new file mode 100644 index 000000000..b190cf13f --- /dev/null +++ b/auth0_flutter/windows/CMakeLists.txt @@ -0,0 +1,140 @@ +# 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.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") +#endif() + +# 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" + "auth0_client.cpp" + "token_decoder.cpp" + "time_util.cpp" + "user_profile.cpp" + "user_identity.cpp" + "jwt_util.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) +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") + +#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(OpenSSL 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 + OpenSSL::SSL + OpenSSL::Crypto + Boost::system + Boost::date_time + Boost::regex +) + +# List of absolute paths to libraries that should be bundled with the plugin. +set(auth0_flutter_bundled_libraries + "" + PARENT_SCOPE +) + +# === Tests === +option(AUTH0_FLUTTER_ENABLE_TESTS "Build auth0_flutter unit tests" ON) + +if (AUTH0_FLUTTER_ENABLE_TESTS) + enable_testing() + + 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 "" FORCE) + FetchContent_MakeAvailable(googletest) + + add_executable(${TEST_RUNNER} + 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 + ) + + target_link_libraries(${TEST_RUNNER} PRIVATE + flutter_wrapper_plugin + gtest_main + gmock + cpprestsdk::cpprest + OpenSSL::SSL + OpenSSL::Crypto + Boost::system + Boost::date_time + Boost::regex + ) + + include(GoogleTest) + gtest_discover_tests(${TEST_RUNNER}) +endif() \ No newline at end of file diff --git a/auth0_flutter/windows/auth0_client.cpp b/auth0_flutter/windows/auth0_client.cpp new file mode 100644 index 000000000..dfcf0c4fe --- /dev/null +++ b/auth0_flutter/windows/auth0_client.cpp @@ -0,0 +1,63 @@ +#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..213110fc5 --- /dev/null +++ b/auth0_flutter/windows/auth0_client.h @@ -0,0 +1,19 @@ +#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 new file mode 100644 index 000000000..d02f4cf79 --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -0,0 +1,629 @@ +#define _CRT_SECURE_NO_WARNINGS +#define _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING +#define NOMINMAX +#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 +#include +#include +#include +#include + +// OpenSSL for PKCE +#include +#include + +// cpprestsdk +#include +#include +#include +#include + +#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; +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; + } + } + + 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 += '='; + } + } + } + + // 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; + } + + 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() + { + 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); + } + + // 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; + } + // 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) + { + 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 -------------------- + + // -------------------- Plugin Impl -------------------- + + void Auth0FlutterPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) + { + auto channel = + std::make_unique>( + registrar->messenger(), "auth0.com/auth0_flutter/web_auth", + &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 DebugPrint(const std::string &msg) + { + OutputDebugStringA((msg + "\n").c_str()); + } + + static std::ostringstream BuildLogoutUrl( + const std::string &domain, + const std::string &clientId, + const std::string &returnTo, + bool federated) + { + std::ostringstream url; + + url << "https://" << domain << "/v2/logout"; + + // Swift: v2/logout?federated + if (federated) + { + url << "?federated"; + } + + // Append query params + char separator = federated ? '&' : '?'; + + if (!returnTo.empty()) + { + url << separator << "returnTo=" << returnTo; + separator = '&'; + } + + url << separator << "client_id=" << clientId; + + return url; + } + + 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; + } + + // 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 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); +BringFlutterWindowToFront(); + // 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); + + 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()); + } + } + 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; + } + + // 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 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 diff --git a/auth0_flutter/windows/auth0_flutter_plugin.h b/auth0_flutter/windows/auth0_flutter_plugin.h new file mode 100644 index 000000000..3923ac0ea --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin.h @@ -0,0 +1,33 @@ +#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/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..26ed58f6c --- /dev/null +++ b/auth0_flutter/windows/credentials.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "user_profile.h" + +class Credentials { + public: + // ===== Raw credential fields ===== + std::string accessToken; + std::string idToken; + std::string tokenType; + + std::optional refreshToken; + std::optional expiresIn; // seconds + std::optional expiresAt; + + std::vector scope; +}; 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/jwt_util.cpp b/auth0_flutter/windows/jwt_util.cpp new file mode 100644 index 000000000..ad9b68743 --- /dev/null +++ b/auth0_flutter/windows/jwt_util.cpp @@ -0,0 +1,114 @@ +#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..981cd338b --- /dev/null +++ b/auth0_flutter/windows/jwt_util.h @@ -0,0 +1,19 @@ +#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/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 diff --git a/auth0_flutter/windows/time_util.cpp b/auth0_flutter/windows/time_util.cpp new file mode 100644 index 000000000..bdbb3cb1b --- /dev/null +++ b/auth0_flutter/windows/time_util.cpp @@ -0,0 +1,48 @@ +#include "time_util.h" + +#include +#include +#include + +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"); + + 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); +} + +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{}; + +#if defined(_WIN32) + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + + 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 new file mode 100644 index 000000000..24d1438e0 --- /dev/null +++ b/auth0_flutter/windows/time_util.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include +#include + +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 new file mode 100644 index 000000000..0bbc03c27 --- /dev/null +++ b/auth0_flutter/windows/token_decoder.cpp @@ -0,0 +1,78 @@ +#include "token_decoder.h" +#include +#include "time_util.h" +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()); + } + + 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()) + { + + auto iso = utility::conversions::to_utf8string( + json.at(U("expires_at")).as_string()); + + creds.expiresAt = ParseIso8601(iso); + } + + // If expires_at missing, compute from expires_in + if (!creds.expiresAt.has_value() && creds.expiresIn.has_value()) + { + creds.expiresAt = + 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.scope.push_back(s); + } + } + + return creds; +} diff --git a/auth0_flutter/windows/token_decoder.h b/auth0_flutter/windows/token_decoder.h new file mode 100644 index 000000000..0a5bef81e --- /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); 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 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