diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1d0798e6..0bd41bd85 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -306,6 +306,55 @@ 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: Set up vcpkg + uses: lukka/run-vcpkg@v11 # pin@v11 + with: + vcpkgDirectory: '${{ github.workspace }}/vcpkg' + vcpkgGitCommitId: '7a26ed4ca1cc6eafc66666166f8f36c49dc5ec18' + + - name: Install cpprestsdk via vcpkg + run: | + ${{ github.workspace }}\vcpkg\vcpkg install cpprestsdk: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: 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/.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/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/Auth0FlutterWebAuthMethodCallHandler.cpp b/auth0_flutter/windows/Auth0FlutterWebAuthMethodCallHandler.cpp new file mode 100644 index 000000000..fa4a25ac8 --- /dev/null +++ b/auth0_flutter/windows/Auth0FlutterWebAuthMethodCallHandler.cpp @@ -0,0 +1,67 @@ +/** + * @file Auth0FlutterWebAuthMethodCallHandler.cpp + * @brief Implementation of Auth0FlutterWebAuthMethodCallHandler + */ + +#include "Auth0FlutterWebAuthMethodCallHandler.h" + +namespace auth0_flutter +{ + + /** + * @brief Constructor - initializes the handler with a list of WebAuth handlers + * + * @param handlers Vector of WebAuthRequestHandler implementations + * Each handler is responsible for a specific WebAuth operation + */ + Auth0FlutterWebAuthMethodCallHandler::Auth0FlutterWebAuthMethodCallHandler( + std::vector> handlers) + : handlers_(std::move(handlers)) + { + } + + /** + * @brief Routes method calls to the appropriate handler + * + * This method implements the routing logic: + * 1. Extract the method name from the method call + * 2. Iterate through registered handlers to find a match + * 3. If a handler matches, delegate to it + * 4. If no handler matches, return NotImplemented + * + * The method also validates that arguments are provided as a map, + * which is required by all WebAuth operations. + * + * @param method_call The method call from Flutter containing method name and arguments + * @param result The result callback to return response to Flutter + */ + void Auth0FlutterWebAuthMethodCallHandler::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) + { + // Get the method name from the call + const std::string &method = method_call.method_name(); + + // All WebAuth methods require arguments to be a map + const auto *args = std::get_if(method_call.arguments()); + if (!args) + { + result->Error("bad_args", "Expected a map as arguments"); + return; + } + + // Find and execute the matching handler + for (const auto &handler : handlers_) + { + if (handler->method() == method) + { + handler->handle(args, std::move(result)); + return; + } + } + + // No handler found for this method + result->NotImplemented(); + } + +} // namespace auth0_flutter diff --git a/auth0_flutter/windows/Auth0FlutterWebAuthMethodCallHandler.h b/auth0_flutter/windows/Auth0FlutterWebAuthMethodCallHandler.h new file mode 100644 index 000000000..67cfe729d --- /dev/null +++ b/auth0_flutter/windows/Auth0FlutterWebAuthMethodCallHandler.h @@ -0,0 +1,85 @@ +/** + * @file Auth0FlutterWebAuthMethodCallHandler.h + * @brief Method call handler for WebAuth channel + * + * This class manages WebAuth method calls from Flutter by routing them to + * appropriate specialized handlers based on the method name. + * + * Pattern: Chain of Responsibility / Strategy pattern + * - Maintains a list of WebAuthRequestHandler implementations + * - Routes incoming method calls to the handler that matches the method name + * - Returns NotImplemented if no handler matches + */ + +#ifndef FLUTTER_PLUGIN_AUTH0_FLUTTER_WEB_AUTH_METHOD_CALL_HANDLER_H_ +#define FLUTTER_PLUGIN_AUTH0_FLUTTER_WEB_AUTH_METHOD_CALL_HANDLER_H_ + +#include +#include +#include +#include +#include "request_handlers/web_auth/WebAuthRequestHandler.h" + +namespace auth0_flutter +{ + + /** + * @class Auth0FlutterWebAuthMethodCallHandler + * @brief Routes WebAuth method calls to appropriate handlers + * + * This class implements the Strategy pattern for handling different WebAuth + * operations. Each operation (login, logout) is implemented by a separate + * handler class, making the code modular and testable. + * + * Usage: + * 1. Create handler instance with list of WebAuthRequestHandlers + * 2. Call HandleMethodCall() when a method is invoked from Flutter + * 3. The handler will route to the appropriate WebAuthRequestHandler + * + * Example: + * @code + * auto handler = std::make_unique( + * std::vector>{ + * std::make_unique(), + * std::make_unique() + * } + * ); + * handler->HandleMethodCall(method_call, std::move(result)); + * @endcode + */ + class Auth0FlutterWebAuthMethodCallHandler + { + public: + /** + * @brief Constructs the handler with a list of WebAuth request handlers + * @param handlers Vector of WebAuthRequestHandler implementations + */ + explicit Auth0FlutterWebAuthMethodCallHandler( + std::vector> handlers); + + ~Auth0FlutterWebAuthMethodCallHandler() = default; + + // Disallow copy and assign + Auth0FlutterWebAuthMethodCallHandler(const Auth0FlutterWebAuthMethodCallHandler &) = delete; + Auth0FlutterWebAuthMethodCallHandler &operator=(const Auth0FlutterWebAuthMethodCallHandler &) = delete; + + /** + * @brief Handles a method call from Flutter + * + * Routes the method call to the appropriate WebAuthRequestHandler based on + * the method name. If no handler matches, returns NotImplemented. + * + * @param method_call The method call from Flutter + * @param result The result callback to send response back to Flutter + */ + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); + + private: + std::vector> handlers_; + }; + +} // namespace auth0_flutter + +#endif // FLUTTER_PLUGIN_AUTH0_FLUTTER_WEB_AUTH_METHOD_CALL_HANDLER_H_ diff --git a/auth0_flutter/windows/CMakeLists.txt b/auth0_flutter/windows/CMakeLists.txt new file mode 100644 index 000000000..6f92acc8a --- /dev/null +++ b/auth0_flutter/windows/CMakeLists.txt @@ -0,0 +1,151 @@ +# 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" + "Auth0FlutterWebAuthMethodCallHandler.cpp" + "Auth0FlutterWebAuthMethodCallHandler.h" + "request_handlers/web_auth/WebAuthRequestHandler.h" + "request_handlers/web_auth/LoginWebAuthRequestHandler.cpp" + "request_handlers/web_auth/LoginWebAuthRequestHandler.h" + "request_handlers/web_auth/LogoutWebAuthRequestHandler.cpp" + "request_handlers/web_auth/LogoutWebAuthRequestHandler.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 + 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 + 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..8dc7b5956 --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin.cpp @@ -0,0 +1,593 @@ +/** + * @file auth0_flutter_plugin.cpp + * @brief Main plugin implementation for Auth0 Flutter Windows + * + * This file contains the main plugin class and helper utilities for WebAuth operations. + * The plugin follows a handler pattern similar to the Android and iOS implementations, + * delegating method calls to specialized handlers. + * + * Architecture: + * - Auth0FlutterPlugin: Main plugin class, registers with Flutter engine + * - Auth0FlutterWebAuthMethodCallHandler: Routes method calls to appropriate handlers + * - LoginWebAuthRequestHandler: Handles webAuth#login + * - LogoutWebAuthRequestHandler: Handles webAuth#logout + * + * Helper utilities: + * - PKCE functions for OAuth security + * - Base64 URL encoding + * - Custom scheme callback handling + * - Window management + */ + +#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" + +// WebAuth handlers +#include "Auth0FlutterWebAuthMethodCallHandler.h" +#include "request_handlers/web_auth/LoginWebAuthRequestHandler.h" +#include "request_handlers/web_auth/LogoutWebAuthRequestHandler.h" + +using namespace web; +using namespace web::http; +using namespace web::http::client; +using namespace web::http::experimental::listener; + +namespace auth0_flutter +{ + + // -------------------- PKCE Helpers -------------------- + + /** + * @brief Base64 URL-safe encode without padding + * + * Encodes binary data to base64 URL-safe format as required by OAuth 2.0 PKCE. + * + * Transformations: + * - '+' → '-' + * - '/' → '_' + * - Removes padding '=' + * + * @param data Binary data to encode + * @return Base64 URL-safe encoded string + */ + 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; + } + + /** + * @brief Brings the Flutter window to the foreground + * + * After the user completes authentication in the browser, this function + * brings the Flutter app window back to focus. Uses Windows-specific APIs + * to bypass foreground lock restrictions. + * + * Technique: + * 1. Restore window if minimized + * 2. Attach input threads to bypass foreground restrictions + * 3. Set window as foreground and focused + */ + 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); + } + + /** + * @brief Generate random code verifier for PKCE flow + * + * Creates a cryptographically random 32-byte value and encodes it as a + * base64 URL-safe string. This is the code verifier used in OAuth 2.0 PKCE. + * + * @return Base64 URL-safe encoded random string (43 characters) + * @throws std::runtime_error if random generation fails + */ + 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); + } + + /** + * @brief Generate code challenge from verifier for PKCE flow + * + * Creates the code challenge by hashing the verifier with SHA256 and + * encoding the result as base64 URL-safe. This challenge is sent in the + * authorization request, and the verifier is sent during token exchange. + * + * Formula: BASE64URL(SHA256(ASCII(verifier))) + * + * @param verifier The code verifier string + * @return Base64 URL-safe encoded SHA256 hash of the verifier + */ + 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) ---------- + + /** + * @brief Decodes a URL-encoded string + * + * Handles percent-encoding (%XX) and plus-to-space conversion. + * + * @param str URL-encoded string + * @return Decoded string + */ + 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; + } + + /** + * @brief Safely parses URL query parameters + * + * Parses a query string (without leading '?') into a map of key-value pairs. + * Handles URL-decoded keys and values. + * + * @param query Query string (e.g., "code=ABC&state=XYZ") + * @return Map of decoded parameter names to values + */ + 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; + } + + /** + * @brief Converts wide string (wchar_t) to UTF-8 + * + * Safely converts Windows wide strings to UTF-8 encoded strings. + * Used for converting environment variable values from Windows API. + * + * @param wstr Wide string to convert + * @return UTF-8 encoded string + */ + 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; + } + + /** + * @brief Wait for OAuth callback via custom scheme (environment variable polling) + * + * Polls the PLUGIN_STARTUP_URL environment variable for the OAuth redirect URI. + * The Windows runner sets this variable when the app is launched via custom scheme + * (auth0flutter://callback?code=...). + * + * Process: + * 1. Poll environment variable every 200ms + * 2. When found, clear the variable and parse the URI + * 3. Extract the 'code' parameter from query string + * 4. Return authorization code or empty string on timeout/error + * + * @param expectedRedirectBase Expected redirect URI prefix (e.g., "auth0flutter://callback") + * @param timeoutSeconds Maximum time to wait (default: 180 seconds / 3 minutes) + * @return Authorization code on success, empty string on timeout/error + * + * 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 -------------------- + + /** + * @brief Wait for OAuth callback via local HTTP listener (alternative approach) + * + * Creates a local HTTP server to receive the OAuth redirect. This is an + * alternative to the custom scheme approach, but requires localhost redirect URIs. + * + * Note: Currently not used in favor of custom scheme approach. + * + * @param redirectUri The redirect URI (e.g., "http://localhost:8080/callback") + * @return Authorization code from the callback + */ + 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; + } + + // -------------------- Plugin Impl -------------------- + + /** + * @brief Registers the plugin with the Flutter engine + * + * Sets up the WebAuth method channel and initializes the plugin with + * all required handlers. This follows the same channel name and architecture + * as Android and iOS implementations. + * + * Channel: "auth0.com/auth0_flutter/web_auth" + * Methods supported: + * - webAuth#login: Handled by LoginWebAuthRequestHandler + * - webAuth#logout: Handled by LogoutWebAuthRequestHandler + * + * @param registrar The Flutter plugin registrar + */ + 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)); + } + + /** + * @brief Constructor - initializes the plugin with WebAuth handlers + * + * Creates and registers all WebAuth request handlers following the + * strategy pattern used in Android and iOS implementations. + */ + Auth0FlutterPlugin::Auth0FlutterPlugin() + { + // Initialize WebAuth method call handler with all request handlers + std::vector> handlers; + handlers.push_back(std::make_unique()); + handlers.push_back(std::make_unique()); + + webAuthCallHandler_ = std::make_unique( + std::move(handlers)); + } + + Auth0FlutterPlugin::~Auth0FlutterPlugin() {} + + /** + * @brief Debug logging utility + * + * Prints debug messages to the Visual Studio Output window using + * OutputDebugString. Visible when debugging in Visual Studio. + * + * @param msg Message to log + */ + void DebugPrint(const std::string &msg) + { + OutputDebugStringA((msg + "\n").c_str()); + } + + /** + * @brief Builds Auth0 logout URL (helper function, now moved to LogoutWebAuthRequestHandler) + * + * This function is deprecated and kept for backward compatibility. + * New code should use LogoutWebAuthRequestHandler instead. + * + * @deprecated Use LogoutWebAuthRequestHandler::BuildLogoutUrl instead + */ + 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; + } + + /** + * @brief Handles method calls from Flutter + * + * Delegates all method calls to the appropriate handler. This implementation + * follows the same pattern as Android and iOS, using a handler-based architecture + * for clean separation of concerns. + * + * All WebAuth methods (login, logout) are handled by webAuthCallHandler_, + * which routes to specialized handlers based on the method name. + * + * @param method_call The method call from Flutter + * @param result Callback to return results to Flutter + */ + void Auth0FlutterPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) + { + // Delegate all method calls to the WebAuth handler + // The handler will route to appropriate specialized handlers based on method name + webAuthCallHandler_->HandleMethodCall(method_call, std::move(result)); + } + +} // 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..2819cbfcc --- /dev/null +++ b/auth0_flutter/windows/auth0_flutter_plugin.h @@ -0,0 +1,84 @@ +/** + * @file auth0_flutter_plugin.h + * @brief Main plugin header for Auth0 Flutter Windows + * + * Defines the Auth0FlutterPlugin class which serves as the entry point + * for the Flutter plugin on Windows. This plugin handles WebAuth operations + * by delegating to specialized handler classes. + */ + +#ifndef FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ +#define FLUTTER_PLUGIN_AUTH0_FLUTTER_PLUGIN_H_ + +#include +#include + +#include + +namespace auth0_flutter +{ + // Forward declaration + class Auth0FlutterWebAuthMethodCallHandler; + + /** + * @class Auth0FlutterPlugin + * @brief Main plugin class for Auth0 Flutter on Windows + * + * This class follows the same architectural pattern as Android and iOS: + * - Registers with Flutter engine + * - Creates method channel for WebAuth operations + * - Delegates method calls to specialized handlers + * + * The plugin uses a handler-based architecture where each WebAuth operation + * (login, logout) is implemented by a separate handler class, making the + * code modular, testable, and consistent with other platforms. + */ + class Auth0FlutterPlugin : public flutter::Plugin + { + public: + /** + * @brief Registers the plugin with the Flutter Windows engine + * @param registrar The plugin registrar provided by Flutter + */ + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + /** + * @brief Constructor - initializes WebAuth handlers + */ + Auth0FlutterPlugin(); + + /** + * @brief Destructor + */ + virtual ~Auth0FlutterPlugin(); + + // Disallow copy and assign. + Auth0FlutterPlugin(const Auth0FlutterPlugin &) = delete; + Auth0FlutterPlugin &operator=(const Auth0FlutterPlugin &) = delete; + + /** + * @brief Handles method calls from Flutter + * + * Routes method calls to the appropriate handler. All WebAuth methods + * are handled by webAuthCallHandler_. + * + * @param method_call The method call from Flutter + * @param result Callback to return results to Flutter + */ + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); + + private: + /** + * @brief Handler for all WebAuth method calls + * + * This handler manages login, logout, and other WebAuth operations + * by routing to appropriate specialized handlers. + */ + std::unique_ptr webAuthCallHandler_; + }; + +} // 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/request_handlers/web_auth/LoginWebAuthRequestHandler.cpp b/auth0_flutter/windows/request_handlers/web_auth/LoginWebAuthRequestHandler.cpp new file mode 100644 index 000000000..7006db406 --- /dev/null +++ b/auth0_flutter/windows/request_handlers/web_auth/LoginWebAuthRequestHandler.cpp @@ -0,0 +1,237 @@ +/** + * @file LoginWebAuthRequestHandler.cpp + * @brief Implementation of LoginWebAuthRequestHandler + */ + +#include "LoginWebAuthRequestHandler.h" +#include "../../auth0_client.h" +#include "../../credentials.h" +#include "../../user_profile.h" +#include "../../jwt_util.h" +#include "../../time_util.h" + +#include +#include +#include +#include +#include +#include + +// OpenSSL for PKCE +#include +#include + +#include + +namespace auth0_flutter +{ + + // Forward declarations of helper functions (defined in auth0_flutter_plugin.cpp) + extern std::string base64UrlEncode(const std::vector &data); + extern std::string generateCodeVerifier(); + extern std::string generateCodeChallenge(const std::string &verifier); + extern std::string waitForAuthCode_CustomScheme(const std::string &expectedRedirectBase, int timeoutSeconds); + extern void BringFlutterWindowToFront(); + extern void DebugPrint(const std::string &msg); + + /** + * @brief Handles the webAuth#login method call + * + * Process: + * 1. Extract and validate required parameters (account, scopes) + * 2. Generate PKCE parameters for secure OAuth flow + * 3. Build authorization URL with all required OAuth parameters + * 4. Open system default browser with authorization URL + * 5. Wait for OAuth callback containing authorization code + * 6. Bring Flutter window back to foreground + * 7. Exchange authorization code for tokens using Auth0 token endpoint + * 8. Parse tokens and extract user profile from ID token + * 9. Return credentials map to Flutter + * + * @param arguments Map containing configuration from Flutter + * @param result Callback to return success/error to Flutter + */ + void LoginWebAuthRequestHandler::handle( + const flutter::EncodableMap *arguments, + std::unique_ptr> result) + { + if (!arguments) + { + result->Error("bad_args", "Expected a map as arguments"); + return; + } + + // Extract "account" map containing clientId and domain + auto accountIt = arguments->find(flutter::EncodableValue("_account")); + if (accountIt == arguments->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 required Auth0 configuration + 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); + } + + // Validate required parameters + if (clientId.empty() || domain.empty()) + { + result->Error("bad_args", "clientId and domain are required"); + return; + } + + // Extract scopes (default: "openid profile email") + std::string scopeStr = "openid profile email"; + + auto scopesIt = arguments->find(flutter::EncodableValue("scopes")); + if (scopesIt != arguments->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(); + } + + // Extract redirect URI (default: "auth0flutter://callback") + std::string redirectUri = "auth0flutter://callback"; + auto redirectIt = arguments->find(flutter::EncodableValue("redirectUrl")); + if (redirectIt != arguments->end()) + { + if (auto s = std::get_if(&redirectIt->second)) + { + redirectUri = *s; + } + } + + try + { + // Step 1: Generate PKCE parameters for secure OAuth flow + // PKCE prevents authorization code interception attacks + std::string codeVerifier = generateCodeVerifier(); + std::string codeChallenge = generateCodeChallenge(codeVerifier); + + DebugPrint("codeVerifier = " + codeVerifier); + DebugPrint("codeChallenge = " + codeChallenge); + + // Step 2: Build OAuth 2.0 authorization URL + // Uses authorization code flow with PKCE + 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"; + + // Step 3: Open system default browser for user authentication + // User will authenticate with Auth0 in their browser + ShellExecuteA(NULL, "open", authUrl.str().c_str(), NULL, NULL, SW_SHOWNORMAL); + + // Step 4: Wait for OAuth callback containing authorization code + // Timeout: 180 seconds (3 minutes) + std::string code = waitForAuthCode_CustomScheme(redirectUri, 180); + + // Step 5: Bring Flutter window back to foreground + BringFlutterWindowToFront(); + + if (code.empty()) + { + result->Error("auth_failed", "Failed to receive authorization code (timeout or user cancelled)"); + return; + } + + // Step 6: Exchange authorization code for tokens + // Sends code verifier to prove we initiated the flow (PKCE validation) + Auth0Client client(domain, clientId); + Credentials creds = client.ExchangeCodeForTokens(redirectUri, code, codeVerifier); + + // Step 7: Build response map with credentials + 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())); + } + + // Convert scopes vector to Flutter list + flutter::EncodableList scopes; + for (const auto &credscope : creds.scope) + { + scopes.emplace_back(credscope); + } + response[flutter::EncodableValue("scopes")] = flutter::EncodableValue(scopes); + + // Step 8: Decode ID token JWT to extract user profile + 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()); + + // Step 9: Return success with credentials + result->Success(flutter::EncodableValue(response)); + } + catch (const std::exception &e) + { + // Handle any errors during the authentication flow + result->Error("auth_failed", e.what()); + } + } + +} // namespace auth0_flutter diff --git a/auth0_flutter/windows/request_handlers/web_auth/LoginWebAuthRequestHandler.h b/auth0_flutter/windows/request_handlers/web_auth/LoginWebAuthRequestHandler.h new file mode 100644 index 000000000..49571fd7a --- /dev/null +++ b/auth0_flutter/windows/request_handlers/web_auth/LoginWebAuthRequestHandler.h @@ -0,0 +1,67 @@ +/** + * @file LoginWebAuthRequestHandler.h + * @brief Handler for WebAuth login method calls + * + * Implements the "webAuth#login" method, which performs OAuth 2.0 authorization code flow + * with PKCE (Proof Key for Code Exchange) for secure authentication. + * + * Flow: + * 1. Generate PKCE code verifier and challenge + * 2. Build authorization URL with required parameters + * 3. Open system browser for user authentication + * 4. Wait for OAuth callback with authorization code + * 5. Exchange authorization code for tokens + * 6. Return credentials (access token, ID token, refresh token, user profile) + */ + +#ifndef FLUTTER_PLUGIN_LOGIN_WEB_AUTH_REQUEST_HANDLER_H_ +#define FLUTTER_PLUGIN_LOGIN_WEB_AUTH_REQUEST_HANDLER_H_ + +#include "WebAuthRequestHandler.h" + +namespace auth0_flutter +{ + + /** + * @class LoginWebAuthRequestHandler + * @brief Handles webAuth#login method calls from Flutter + * + * Configuration parameters supported: + * - _account (required): Map containing: + * - clientId: Auth0 application client ID + * - domain: Auth0 tenant domain + * - scopes: List of OAuth scopes (default: ["openid", "profile", "email"]) + * - audience: API identifier for which to request tokens + * - redirectUrl: OAuth callback URL (default: "auth0flutter://callback") + * - organizationId: Organization ID for multi-tenant setups + * - invitationUrl: Invitation URL for organization invites + * - parameters: Custom parameters for Auth0 Rules/Actions + * + * Response structure: + * - accessToken: OAuth 2.0 access token + * - idToken: OpenID Connect ID token (JWT) + * - refreshToken: Refresh token (if available) + * - tokenType: Token type (typically "Bearer") + * - expiresAt: ISO8601 formatted expiration timestamp + * - scopes: List of granted scopes + * - userProfile: Map containing user profile claims from ID token + */ + class LoginWebAuthRequestHandler : public WebAuthRequestHandler + { + public: + LoginWebAuthRequestHandler() = default; + ~LoginWebAuthRequestHandler() override = default; + + std::string method() const override + { + return "webAuth#login"; + } + + void handle( + const flutter::EncodableMap *arguments, + std::unique_ptr> result) override; + }; + +} // namespace auth0_flutter + +#endif // FLUTTER_PLUGIN_LOGIN_WEB_AUTH_REQUEST_HANDLER_H_ diff --git a/auth0_flutter/windows/request_handlers/web_auth/LogoutWebAuthRequestHandler.cpp b/auth0_flutter/windows/request_handlers/web_auth/LogoutWebAuthRequestHandler.cpp new file mode 100644 index 000000000..70a09cb7d --- /dev/null +++ b/auth0_flutter/windows/request_handlers/web_auth/LogoutWebAuthRequestHandler.cpp @@ -0,0 +1,162 @@ +/** + * @file LogoutWebAuthRequestHandler.cpp + * @brief Implementation of LogoutWebAuthRequestHandler + */ + +#include "LogoutWebAuthRequestHandler.h" +#include +#include + +namespace auth0_flutter +{ + + /** + * @brief Builds the Auth0 logout URL with required parameters + * + * The logout URL follows the Auth0 logout endpoint specification: + * https://{domain}/v2/logout + * + * Query parameters: + * - federated: If true, also logs out from the identity provider + * - returnTo: URL to redirect after logout completes + * - client_id: Auth0 application client ID + * + * @param domain Auth0 tenant domain + * @param clientId Auth0 application client ID + * @param returnTo URL to redirect after logout + * @param federated Whether to perform federated logout + * @return Logout URL as string + */ + static std::string BuildLogoutUrl( + const std::string &domain, + const std::string &clientId, + const std::string &returnTo, + bool federated) + { + std::ostringstream url; + + // Base logout endpoint + url << "https://" << domain << "/v2/logout"; + + // Add federated parameter if requested + // This will also log the user out from their identity provider + if (federated) + { + url << "?federated"; + } + + // Determine query parameter separator + char separator = federated ? '&' : '?'; + + // Add returnTo URL if provided + if (!returnTo.empty()) + { + url << separator << "returnTo=" << returnTo; + separator = '&'; + } + + // Add client_id (required by Auth0) + url << separator << "client_id=" << clientId; + + return url.str(); + } + + /** + * @brief Handles the webAuth#logout method call + * + * Process: + * 1. Extract and validate required parameters (account) + * 2. Extract optional parameters (returnTo, federated) + * 3. Build logout URL with all parameters + * 4. Open system default browser with logout URL + * 5. Return success immediately (logout is fire-and-forget) + * + * Note: Unlike login, logout does not wait for a callback. The browser + * operation is asynchronous and the user may close the browser tab after + * logout completes. + * + * @param arguments Map containing configuration from Flutter + * @param result Callback to return success/error to Flutter + */ + void LogoutWebAuthRequestHandler::handle( + const flutter::EncodableMap *arguments, + std::unique_ptr> result) + { + if (!arguments) + { + result->Error("bad_args", "Expected a map as arguments"); + return; + } + + // Extract "account" map containing clientId and domain + auto accountIt = arguments->find(flutter::EncodableValue("_account")); + if (accountIt == arguments->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 required Auth0 configuration + 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); + } + + // Validate required parameters + if (clientId.empty() || domain.empty()) + { + result->Error("bad_args", "clientId and domain are required"); + return; + } + + // Extract returnTo URL (default: "auth0flutter://callback") + std::string returnTo = "auth0flutter://callback"; + auto returnToIt = arguments->find(flutter::EncodableValue("returnTo")); + if (returnToIt != arguments->end()) + { + if (auto s = std::get_if(&returnToIt->second)) + { + returnTo = *s; + } + } + + // Extract federated flag (default: false) + bool federated = false; + auto fedIt = arguments->find(flutter::EncodableValue("federated")); + if (fedIt != arguments->end()) + { + if (auto b = std::get_if(&fedIt->second)) + { + federated = *b; + } + } + + // Build logout URL with all parameters + std::string logoutUrl = BuildLogoutUrl(domain, clientId, returnTo, federated); + + // Open logout URL in system default browser + // This is a fire-and-forget operation - we don't wait for completion + ShellExecuteA(NULL, "open", logoutUrl.c_str(), NULL, NULL, SW_SHOWNORMAL); + + // Return success immediately + result->Success(flutter::EncodableValue()); + } + +} // namespace auth0_flutter diff --git a/auth0_flutter/windows/request_handlers/web_auth/LogoutWebAuthRequestHandler.h b/auth0_flutter/windows/request_handlers/web_auth/LogoutWebAuthRequestHandler.h new file mode 100644 index 000000000..41283d68b --- /dev/null +++ b/auth0_flutter/windows/request_handlers/web_auth/LogoutWebAuthRequestHandler.h @@ -0,0 +1,60 @@ +/** + * @file LogoutWebAuthRequestHandler.h + * @brief Handler for WebAuth logout method calls + * + * Implements the "webAuth#logout" method, which performs Auth0 logout by opening + * the Auth0 logout endpoint in the system browser. This clears the user's session + * and optionally performs federated logout across identity providers. + * + * Flow: + * 1. Extract logout configuration parameters + * 2. Build logout URL with returnTo and federated parameters + * 3. Open system browser with logout URL + * 4. Return success immediately (logout is fire-and-forget) + */ + +#ifndef FLUTTER_PLUGIN_LOGOUT_WEB_AUTH_REQUEST_HANDLER_H_ +#define FLUTTER_PLUGIN_LOGOUT_WEB_AUTH_REQUEST_HANDLER_H_ + +#include "WebAuthRequestHandler.h" + +namespace auth0_flutter +{ + + /** + * @class LogoutWebAuthRequestHandler + * @brief Handles webAuth#logout method calls from Flutter + * + * Configuration parameters supported: + * - _account (required): Map containing: + * - clientId: Auth0 application client ID + * - domain: Auth0 tenant domain + * - returnTo: URL to redirect after logout (default: "auth0flutter://callback") + * - federated: Whether to perform federated logout (clears IdP session too) + * + * Logout URL format: + * https://{domain}/v2/logout?federated&returnTo={returnTo}&client_id={clientId} + * + * Response: + * - Returns null/void on success (logout is asynchronous, no callback needed) + * - Returns error if parameters are invalid + */ + class LogoutWebAuthRequestHandler : public WebAuthRequestHandler + { + public: + LogoutWebAuthRequestHandler() = default; + ~LogoutWebAuthRequestHandler() override = default; + + std::string method() const override + { + return "webAuth#logout"; + } + + void handle( + const flutter::EncodableMap *arguments, + std::unique_ptr> result) override; + }; + +} // namespace auth0_flutter + +#endif // FLUTTER_PLUGIN_LOGOUT_WEB_AUTH_REQUEST_HANDLER_H_ diff --git a/auth0_flutter/windows/request_handlers/web_auth/WebAuthRequestHandler.h b/auth0_flutter/windows/request_handlers/web_auth/WebAuthRequestHandler.h new file mode 100644 index 000000000..3ba29c7a0 --- /dev/null +++ b/auth0_flutter/windows/request_handlers/web_auth/WebAuthRequestHandler.h @@ -0,0 +1,59 @@ +/** + * @file WebAuthRequestHandler.h + * @brief Base interface for handling WebAuth method calls from Flutter + * + * This abstract base class defines the interface for WebAuth request handlers. + * Concrete implementations handle specific WebAuth operations like login and logout. + * + * Pattern: Strategy pattern - allows different handlers for different WebAuth methods + * while maintaining a consistent interface. + */ + +#ifndef FLUTTER_PLUGIN_WEB_AUTH_REQUEST_HANDLER_H_ +#define FLUTTER_PLUGIN_WEB_AUTH_REQUEST_HANDLER_H_ + +#include +#include +#include +#include + +namespace auth0_flutter +{ + + /** + * @class WebAuthRequestHandler + * @brief Abstract base class for WebAuth method handlers + * + * Each concrete handler implements: + * - method(): Returns the method name this handler responds to (e.g., "webAuth#login") + * - handle(): Processes the method call and returns the result via the result callback + */ + class WebAuthRequestHandler + { + public: + virtual ~WebAuthRequestHandler() = default; + + /** + * @brief Get the method name this handler responds to + * @return The method name (e.g., "webAuth#login", "webAuth#logout") + */ + virtual std::string method() const = 0; + + /** + * @brief Handle the method call + * @param arguments The arguments map from Flutter containing configuration + * @param result The result callback to send the response back to Flutter + * + * The handler should: + * 1. Validate and extract required arguments + * 2. Perform the WebAuth operation (login, logout, etc.) + * 3. Call result->Success() with the response data, or result->Error() on failure + */ + virtual void handle( + const flutter::EncodableMap *arguments, + std::unique_ptr> result) = 0; + }; + +} // namespace auth0_flutter + +#endif // FLUTTER_PLUGIN_WEB_AUTH_REQUEST_HANDLER_H_ 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/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()); +} 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