diff --git a/.github/scripts/combine_e2e_summary.js b/.github/scripts/combine_e2e_summary.js new file mode 100644 index 00000000..1cd9f473 --- /dev/null +++ b/.github/scripts/combine_e2e_summary.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +function main() { + const args = process.argv.slice(2); + let artifactsDir = ""; + let summaryFile = ""; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--artifacts-dir" && args[i + 1]) artifactsDir = args[++i]; + if (args[i] === "--summary" && args[i + 1]) summaryFile = args[++i]; + } + + if (!artifactsDir) { + console.error("Usage: combine_e2e_summary.js --artifacts-dir --summary "); + process.exit(1); + } + + const lines = ["# Flutter Embedded E2E Summary\n"]; + let totalTests = 0; + let totalPassed = 0; + let totalFailed = 0; + + if (fs.existsSync(artifactsDir)) { + const entries = fs.readdirSync(artifactsDir); + for (const entry of entries.sort()) { + const entryPath = path.join(artifactsDir, entry); + if (!fs.statSync(entryPath).isDirectory()) continue; + + lines.push(`## ${entry}\n`); + + const xmlFiles = (fs.readdirSync(entryPath) || []).filter((f) => f.endsWith(".xml")); + if (xmlFiles.length === 0) { + lines.push("No JUnit XML reports found.\n"); + continue; + } + + for (const xmlFile of xmlFiles) { + const content = fs.readFileSync(path.join(entryPath, xmlFile), "utf-8"); + const testsMatch = content.match(/tests="(\d+)"/); + const failuresMatch = content.match(/failures="(\d+)"/); + const errorsMatch = content.match(/errors="(\d+)"/); + + const tests = testsMatch ? parseInt(testsMatch[1], 10) : 0; + const failures = (failuresMatch ? parseInt(failuresMatch[1], 10) : 0) + + (errorsMatch ? parseInt(errorsMatch[1], 10) : 0); + const passed = tests - failures; + + totalTests += tests; + totalPassed += passed; + totalFailed += failures; + + const emoji = failures > 0 ? "❌" : "✅"; + lines.push(`${emoji} **${xmlFile}**: ${passed}/${tests} passed\n`); + } + } + } + + lines.unshift(""); + lines.unshift(`**Total**: ${totalPassed}/${totalTests} passed, ${totalFailed} failed`); + lines.unshift(""); + + const output = lines.join("\n"); + + if (summaryFile) { + fs.writeFileSync(summaryFile, output); + console.log(`Summary written to ${summaryFile}`); + } else { + console.log(output); + } +} + +main(); diff --git a/.github/scripts/generate_e2e_matrix.js b/.github/scripts/generate_e2e_matrix.js new file mode 100644 index 00000000..a04493af --- /dev/null +++ b/.github/scripts/generate_e2e_matrix.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +// One scenario per shard: pairs of heavy tests (login + 240s token waits) still +// exceeded 90m; isolating avoids a single stuck/hung test blocking a whole pair. +const MAX_TESTS_PER_SHARD = 1; + +const ROOT = path.resolve(__dirname, "../.."); +const CONFIG = { + catalog: path.join(ROOT, "embedded/e2e/scenario-catalog.json"), + testSources: [ + path.join(ROOT, "embedded/integration_test/e2e/embedded_e2e_test.dart"), + ], +}; + +function readCatalogMethods(catalogPath) { + const raw = JSON.parse(fs.readFileSync(catalogPath, "utf-8")); + const entries = raw.tests || raw.scenarios || []; + return entries + .map((entry) => entry.method) + .filter((method) => typeof method === "string" && method.length > 0); +} + +function readDartTestMethods(testSources) { + const methods = new Set(); + const re = /e2ePatrolTest\(\s*'(test[A-Za-z0-9_]+)'/gm; + for (const sourcePath of testSources) { + const source = fs.readFileSync(sourcePath, "utf-8"); + for (const match of source.matchAll(re)) { + methods.add(match[1]); + } + } + return [...methods]; +} + +function parseExcludeList(raw) { + return new Set( + String(raw || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +function validateCatalog(catalogMethods, sourceMethods) { + const catalogSet = new Set(catalogMethods); + const sourceSet = new Set(sourceMethods); + const catalogOnly = catalogMethods.filter((m) => !sourceSet.has(m)); + const sourceOnly = sourceMethods.filter((m) => !catalogSet.has(m)); + if (catalogOnly.length === 0 && sourceOnly.length === 0) return; + const problems = []; + if (catalogOnly.length) problems.push(`catalog-only: ${catalogOnly.join(", ")}`); + if (sourceOnly.length) problems.push(`uncatalogued: ${sourceOnly.join(", ")}`); + throw new Error(`embedded E2E catalog drift: ${problems.join("; ")}`); +} + +/** When building a platform-specific shard list, excluded tests stay in the repo/catalog but are omitted from the matrix. */ +function validateShardMethods(shardMethods, sourceMethods) { + const sourceSet = new Set(sourceMethods); + const unknown = shardMethods.filter((m) => !sourceSet.has(m)); + if (unknown.length) { + throw new Error(`shard methods not found in Dart sources: ${unknown.join(", ")}`); + } +} + +function splitIntoShards(items, shardCount) { + const shards = Array.from({ length: shardCount }, () => []); + items.forEach((item, i) => shards[i % shardCount].push(item)); + return shards; +} + +function main() { + const parsed = parseInt(process.env.INPUT_SHARD_COUNT || "1", 10); + const shardCount = Number.isNaN(parsed) ? 1 : Math.max(1, parsed); + + const catalogMethods = readCatalogMethods(CONFIG.catalog); + const exclude = parseExcludeList(process.env.INPUT_EXCLUDE_METHODS); + const methods = exclude.size + ? catalogMethods.filter((m) => !exclude.has(m)) + : catalogMethods; + const sourceMethods = readDartTestMethods(CONFIG.testSources); + if (exclude.size) { + for (const m of exclude) { + if (!catalogMethods.includes(m)) { + throw new Error(`INPUT_EXCLUDE_METHODS: not in catalog: ${m}`); + } + } + validateShardMethods(methods, sourceMethods); + } else { + validateCatalog(catalogMethods, sourceMethods); + } + + const autoShards = Math.ceil(methods.length / MAX_TESTS_PER_SHARD); + const effectiveShardCount = + shardCount > 1 ? Math.min(shardCount, methods.length || 1) : Math.max(1, autoShards); + + const include = []; + if (effectiveShardCount <= 1 || methods.length === 0) { + include.push({ + "shard-index": 1, + "shard-total": 1, + "test-methods": "", + }); + } else { + const shards = splitIntoShards(methods, effectiveShardCount); + shards.forEach((shard, i) => { + include.push({ + "shard-index": i + 1, + "shard-total": effectiveShardCount, + "test-methods": shard.join(","), + }); + }); + } + + const matrix = JSON.stringify({ include }); + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `matrix=${matrix}\n`); + } + console.log(matrix); +} + +main(); diff --git a/.github/scripts/run_embedded_e2e_shard.sh b/.github/scripts/run_embedded_e2e_shard.sh new file mode 100755 index 00000000..f72e3ba4 --- /dev/null +++ b/.github/scripts/run_embedded_e2e_shard.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +METHODS="${E2E_METHODS:-}" +MAX_RETRIES="${PATROL_MAX_RETRIES:-1}" + +cd embedded +TEST_FILE="integration_test/e2e/embedded_e2e_test.dart" + +flutter pub get + +run_patrol() { + local -a cmd=(patrol test) + if [[ -n "${IOS_DEVICE:-}" ]]; then + cmd+=(-d "$IOS_DEVICE") + fi + cmd+=(-t "$TEST_FILE") + cmd+=("$@") + cmd+=(--uninstall) + if [[ "${PATROL_VERBOSE:-}" == "1" ]]; then + cmd+=(--verbose) + fi + local capture + capture="$(mktemp)" + set +e + if [[ -n "${PATROL_LOG_FILE:-}" ]]; then + "${cmd[@]}" 2>&1 | tee -a "$PATROL_LOG_FILE" | tee "$capture" + local rc="${PIPESTATUS[0]}" + else + "${cmd[@]}" 2>&1 | tee "$capture" + local rc="${PIPESTATUS[0]}" + fi + set -e + # Patrol CLI 3.6.0 has a known bug in PatrolLogReader (`Bad state: No element`) + # that crashes the wrapper, returning a non-zero exit code. + # + # Case 1: crash AFTER every test passed — treat as pass. + # Case 2: crash BEFORE any test result was reported — flake, let the + # outer retry loop try again instead of failing the shard. + # Case 3: crash WITH an observed FAILED result — genuine failure, propagate. + if [[ "$rc" -ne 0 ]] && grep -q "Bad state: No element" "$capture" \ + && grep -q "PatrolLogReader.readEntries" "$capture"; then + local has_failed_result + has_failed_result=0 + if grep -q "test result: FAILED" "$capture"; then has_failed_result=1; fi + if grep -q "❌ Failed: [1-9]" "$capture"; then has_failed_result=1; fi + + if [[ "$has_failed_result" -eq 0 ]]; then + if grep -q "❌ Failed: 0" "$capture" && grep -q "✅ Successful:" "$capture"; then + echo "::warning::Patrol CLI crashed in PatrolLogReader after success — treating as pass" + rm -f "$capture" + return 0 + fi + if grep -q "test result: PASSED" "$capture"; then + echo "::warning::Patrol CLI crashed in PatrolLogReader after success — treating as pass" + rm -f "$capture" + return 0 + fi + # Crash BEFORE any test result was emitted — pure Patrol flake. + # Return a distinct exit code so the outer retry loop retries. + echo "::warning::Patrol CLI crashed in PatrolLogReader before any test result — treating as flake, will retry" + rm -f "$capture" + return 77 + fi + fi + rm -f "$capture" + return "$rc" +} + +# Best-effort recovery between retries (Android emulator / adb flakes, e.g. exit 224). +recover_between_retries() { + if command -v adb >/dev/null 2>&1; then + adb kill-server || true + adb start-server || true + sleep 3 + fi +} + +run_with_retries() { + local -a patrol_args=("$@") + local attempt=1 + local status=0 + while [[ "$attempt" -le "$MAX_RETRIES" ]]; do + if [[ "$attempt" -gt 1 ]]; then + echo "::warning::Patrol attempt $((attempt - 1)) failed; retrying ($attempt/$MAX_RETRIES)..." + recover_between_retries + fi + set +e + run_patrol "${patrol_args[@]}" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + return 0 + fi + attempt=$((attempt + 1)) + done + # Normalize sentinel exit codes (77 = patrol flake, retries exhausted) to 1 + # so the shell step fails with a clean non-zero rather than a surprising code. + if [[ "$status" -eq 77 ]]; then + return 1 + fi + return "$status" +} + +# One patrol invocation per shard: comma-separated E2E_TEST_FILTER matches multiple +# e2ePatrolTest entries without paying for N× iOS xcodebuild + install cycles. +if [[ -n "$METHODS" ]]; then + echo "::notice::Running E2E shard tests: $METHODS" + run_with_retries --dart-define="E2E_TEST_FILTER=$METHODS" + exit $? +fi + +echo "::notice::Running full E2E suite" +run_with_retries diff --git a/.github/workflows/demo-e2e.yml b/.github/workflows/demo-e2e.yml new file mode 100644 index 00000000..2d715095 --- /dev/null +++ b/.github/workflows/demo-e2e.yml @@ -0,0 +1,315 @@ +name: Demo Embedded E2E (Flutter) + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + checks: write + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [master, main] + workflow_dispatch: + +concurrency: + group: demo-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + matrix_android: ${{ steps.set-matrix-android.outputs.matrix_android }} + steps: + - uses: actions/checkout@v5 + # Webview-based login flows have not yet been validated in CI. + # iOS matrix: tests that work in the current Patrol/Flutter setup. + # + # WEBVIEW LOGIN BLOCKER: All tests that require an embedded WebView login + # are excluded. The Frontegg iOS SDK's CustomWebView blocks navigation + # to 127.0.0.1, and the only way to whitelist it (FronteggRuntime.isTesting, + # added in master) requires `frontegg-testing=true` to be in + # ProcessInfo.processInfo.environment AT PROCESS START. Patrol launches + # the Flutter app via XCUITest with default launch params and gives us no + # hook to inject app.launchEnvironment, so we cannot meet that contract. + # See frontegg-ios-swift demo-embedded-e2e for the working pattern (uses + # XCUIApplication.launchEnvironment directly from the test class). + # + # ANDROID PARITY: The Android Frontegg SDK has no equivalent of + # FronteggRuntime.isTesting, no LocalMockAuthServer, no manualInit, and + # no offline mode — confirmed by reading frontegg-android-kotlin master. + # Webview-login E2E will require upstream SDK work on the Android side. + - id: set-matrix + env: + INPUT_EXCLUDE_METHODS: >- + testPasswordLoginAndSessionRestore, + testEmbeddedSamlLogin, + testEmbeddedOidcLogin, + testCustomSSOBrowserHandoff, + testDirectSocialBrowserHandoff, + testEmbeddedGoogleSocialLoginWithSystemWebAuthenticationSession, + testEmbeddedGoogleSocialLoginOAuthErrorShowsToastAndKeepsLoginOpen, + testLogoutTerminateTransientProbeFailureDoesNotBlinkNoConnectionPage, + testAuthenticatedOfflineModeWhenNetworkPathUnavailable, + testExpiredAccessTokenRefreshesOnAuthenticatedRelaunch, + testAuthenticatedOfflineModeRecoversToOnlineAndRefreshesToken, + testAuthenticatedOfflineModeKeepsUserLoggedInUntilReconnectRefreshesExpiredToken, + testLogoutTerminateTransientNoConnectionThenCustomSSORecovers, + testOfflineModeDisabledPreservesSessionDuringConnectionLossAndRecovers, + testPasswordLoginWorksWithOfflineModeDisabled, + testLogoutClearsSessionAndRelaunchShowsLogin, + testExpiredRefreshTokenClearsSessionAndShowsLogin, + testScheduledTokenRefreshFiresBeforeExpiry, + testAuthenticatedRelaunchWithExpiredAccessTokenAndFreshRefreshToken + run: | + JSON=$(env -u GITHUB_OUTPUT node .github/scripts/generate_e2e_matrix.js) + { + echo "matrix<> "$GITHUB_OUTPUT" + # Android matrix: only non-login tests pass today. + # + # Tried enabling testPasswordLoginAndSessionRestore / testEmbeddedSamlLogin / + # testEmbeddedOidcLogin / testLogoutClearsSessionAndRelaunchShowsLogin — all + # fail with `visible: LoginPageRoot` after 60s, same signature as iOS. The + # reflection-based singleton replace + re-subscribe fix resolves the stale- + # observer problem but there's a second issue in the Android login flow that + # isn't diagnosable from CI logs alone (Patrol doesn't surface logcat). + # Likely candidates: EmbeddedAuthActivity not receiving the deep-link + # callback through CustomSchemeActivity, or the SDK picking up stale + # BuildConfig values despite the rebind. Revisit once frontegg-android-kotlin + # v1.3.26 ships the first-party `initializeEmbeddedForLocalE2E` API. + - id: set-matrix-android + env: + INPUT_EXCLUDE_METHODS: >- + testPasswordLoginAndSessionRestore, + testEmbeddedSamlLogin, + testEmbeddedOidcLogin, + testRequestAuthorizeFlow, + testCustomSSOBrowserHandoff, + testDirectSocialBrowserHandoff, + testEmbeddedGoogleSocialLoginWithSystemWebAuthenticationSession, + testEmbeddedGoogleSocialLoginOAuthErrorShowsToastAndKeepsLoginOpen, + testLogoutTerminateTransientProbeFailureDoesNotBlinkNoConnectionPage, + testAuthenticatedOfflineModeWhenNetworkPathUnavailable, + testExpiredAccessTokenRefreshesOnAuthenticatedRelaunch, + testAuthenticatedOfflineModeRecoversToOnlineAndRefreshesToken, + testAuthenticatedOfflineModeKeepsUserLoggedInUntilReconnectRefreshesExpiredToken, + testLogoutTerminateTransientNoConnectionThenCustomSSORecovers, + testOfflineModeDisabledPreservesSessionDuringConnectionLossAndRecovers, + testPasswordLoginWorksWithOfflineModeDisabled, + testLogoutClearsSessionAndRelaunchShowsLogin, + testExpiredRefreshTokenClearsSessionAndShowsLogin, + testScheduledTokenRefreshFiresBeforeExpiry, + testAuthenticatedRelaunchWithExpiredAccessTokenAndFreshRefreshToken + run: | + JSON=$(env -u GITHUB_OUTPUT node .github/scripts/generate_e2e_matrix.js) + { + echo "matrix_android<> "$GITHUB_OUTPUT" + + embedded-e2e-android: + runs-on: ubuntu-latest + # 1 test/shard + build + emulator; allow long token/offline scenarios + timeout-minutes: 120 + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.matrix_android) }} + env: + # Retries double runtime and often hit the job timeout while the first run is stuck. + PATROL_MAX_RETRIES: "2" + steps: + - uses: actions/checkout@v5 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Install Patrol CLI + run: dart pub global activate patrol_cli 3.6.0 + + - name: Install dependencies + working-directory: embedded + run: flutter pub get + + - name: Validate E2E scenario catalog + run: env -u GITHUB_OUTPUT node .github/scripts/generate_e2e_matrix.js > /dev/null + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build debug APK + test APK + working-directory: embedded + run: patrol build android --target integration_test/e2e/embedded_e2e_test.dart + + - name: Run embedded E2E on emulator + env: + E2E_METHODS: ${{ matrix['test-methods'] }} + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-boot-timeout: 300 + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim + script: bash .github/scripts/run_embedded_e2e_shard.sh + + - name: Collect test reports + if: always() + run: | + mkdir -p e2e-artifacts + find . -name "TEST-*.xml" -exec cp -f {} e2e-artifacts/ \; 2>/dev/null || true + + # Annotate only: do not create a second GitHub Check per shard (the matrix job + # already reports pass/fail; duplicate check_name was cluttering the PR checks list). + - name: JUnit report (annotations + job summary) + uses: mikepenz/action-junit-report@v5 + if: always() + with: + report_paths: e2e-artifacts/*.xml + annotate_only: true + + # continue-on-error: transient GitHub Actions blob-storage ENOTFOUND has + # failed entire shards whose tests passed cleanly. Artifact loss is + # annoying but not a reason to mark the suite red. + - uses: actions/upload-artifact@v6 + if: always() + continue-on-error: true + with: + name: flutter-e2e-shard-${{ matrix['shard-index'] }} + path: e2e-artifacts/ + if-no-files-found: ignore + + embedded-e2e-ios: + # Apple Silicon runner — matches frontegg-ios-swift demo-e2e workflow. + runs-on: macos-15-xlarge + timeout-minutes: 120 + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} + env: + PATROL_MAX_RETRIES: "2" + steps: + - uses: actions/checkout@v5 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Enable Swift Package Manager + run: flutter config --enable-swift-package-manager + + - name: Reset SwiftPM caches (force fresh FronteggSwift resolution) + run: | + rm -rf ~/Library/Developer/Xcode/DerivedData + rm -rf ~/Library/Caches/org.swift.swiftpm + rm -rf ~/Library/org.swift.swiftpm + + - name: Install Patrol CLI + run: dart pub global activate patrol_cli 3.6.0 + + - name: Install dependencies + working-directory: embedded + run: flutter pub get + + - name: Fix generated SPM platform version + run: | + PKG="embedded/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift" + sed -i '' 's/.iOS("13.0")/.iOS("14.0")/' "$PKG" + + - name: Install pods + working-directory: embedded/ios + run: pod install + + - name: Verify FronteggSwift revision pinned in plugin + run: | + echo "=== ios/frontegg_flutter/Package.swift ===" + grep -A1 "frontegg-ios-swift" ios/frontegg_flutter/Package.swift + echo "=== embedded/ios/Runner.xcworkspace/.../Package.resolved ===" + cat embedded/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved | grep -A4 frontegg-ios-swift + + # Match frontegg-ios-swift demo-e2e: same DEVELOPMENT_TEAM in the Xcode project and + # iPhone 16 Pro simulator. Shard tests via E2E_METHODS + E2E_TEST_FILTER (same as Android). + - name: Run embedded E2E on iOS Simulator + env: + E2E_METHODS: ${{ matrix['test-methods'] }} + PATROL_VERBOSE: "1" + run: | + set -euo pipefail + LOG="${RUNNER_TEMP}/ios-e2e-shard-${{ matrix['shard-index'] }}.log" + : > "$LOG" + export PATROL_LOG_FILE="$LOG" + UDID=$(xcrun simctl list devices available -j | jq -r ' + [.devices | to_entries[].value[] + | select(.isAvailable == true and .name == "iPhone 16 Pro")] + | .[0].udid // empty') + if [[ -z "$UDID" || "$UDID" == "null" ]]; then + UDID=$(xcrun simctl list devices available -j | jq -r ' + [.devices | to_entries[] | .value[] + | select(.isAvailable == true and (.name | startswith("iPhone")))] + | sort_by(.name) | .[0].udid // empty') + fi + if [[ -z "$UDID" || "$UDID" == "null" ]]; then + echo "::error::No available iPhone simulator" + xcrun simctl list devices available + exit 1 + fi + export IOS_DEVICE="$UDID" + echo "::notice::Using iOS Simulator UDID=$UDID" + xcrun simctl boot "$UDID" 2>/dev/null || true + xcrun simctl bootstatus "$UDID" -b + open -a Simulator || true + sleep 5 + bash .github/scripts/run_embedded_e2e_shard.sh + + - uses: actions/upload-artifact@v6 + if: always() + continue-on-error: true + with: + name: flutter-e2e-ios-shard-${{ matrix['shard-index'] }} + path: | + ${{ runner.temp }}/ios-e2e-shard-${{ matrix['shard-index'] }}.log + embedded/build/ios_integ/ + if-no-files-found: ignore + + summary: + runs-on: ubuntu-latest + needs: [embedded-e2e-android, embedded-e2e-ios] + if: always() + steps: + - uses: actions/download-artifact@v6 + continue-on-error: true + with: + path: all-e2e + + - uses: actions/checkout@v5 + + - name: Combine summary + run: node .github/scripts/combine_e2e_summary.js --artifacts-dir all-e2e --summary e2e-summary.md + + - name: Upload summary + uses: actions/upload-artifact@v6 + continue-on-error: true + with: + name: e2e-summary + path: e2e-summary.md diff --git a/.github/workflows/onPullRequestUpdated.yaml b/.github/workflows/onPullRequestUpdated.yaml index 5f8dae47..a43ef37a 100644 --- a/.github/workflows/onPullRequestUpdated.yaml +++ b/.github/workflows/onPullRequestUpdated.yaml @@ -9,7 +9,7 @@ on: branches: - master jobs: - build: + validate-pr-description: runs-on: 'ubuntu-latest' steps: - name: Checkout code diff --git a/.github/workflows/onPush.yaml b/.github/workflows/onPush.yaml index 8c6ab42c..739d4946 100644 --- a/.github/workflows/onPush.yaml +++ b/.github/workflows/onPush.yaml @@ -4,18 +4,35 @@ on: push: branches-ignore: - master + jobs: + check-pr: + runs-on: ubuntu-latest + outputs: + has_pr: ${{ steps.check.outputs.has_pr }} + steps: + - id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_COUNT=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq length) + echo "has_pr=$( [ "$PR_COUNT" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT" + build: - runs-on: 'macos-latest-large' + needs: check-pr + # Skip when an open PR exists — PR-triggered workflows already cover this branch. + if: needs.check-pr.outputs.has_pr != 'true' + # Standard runner avoids macos-latest-large, which requires higher org + # spending limits and fails fast when billing / quota is exceeded. + runs-on: macos-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Set up Flutter uses: subosito/flutter-action@v2 with: - architecture: x64 - flutter-version: 3.41.0 + channel: stable - name: Enable Swift Package Manager (SPM) run: flutter config --enable-swift-package-manager diff --git a/android/src/main/kotlin/com/frontegg/flutter/FronteggFlutterPlugin.kt b/android/src/main/kotlin/com/frontegg/flutter/FronteggFlutterPlugin.kt index 406ed8fc..489e16b3 100644 --- a/android/src/main/kotlin/com/frontegg/flutter/FronteggFlutterPlugin.kt +++ b/android/src/main/kotlin/com/frontegg/flutter/FronteggFlutterPlugin.kt @@ -26,14 +26,15 @@ class FronteggFlutterPlugin : FlutterPlugin, ActivityAware, ActivityProvider { this, flutterPluginBinding.applicationContext, ) - + channel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL_NAME) channel.setMethodCallHandler(methodCallHandler) stateEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, STATE_EVENT_CHANNEL_NAME) stateListener = FronteggStateListenerImpl(flutterPluginBinding.applicationContext) - + activeStateListener = stateListener + // Set the state listener in method call handler methodCallHandler.setStateListener(stateListener!!) @@ -52,6 +53,7 @@ class FronteggFlutterPlugin : FlutterPlugin, ActivityAware, ActivityProvider { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) context = null + if (activeStateListener === stateListener) activeStateListener = null stateListener?.dispose() } @@ -78,5 +80,16 @@ class FronteggFlutterPlugin : FlutterPlugin, ActivityAware, ActivityProvider { companion object { const val METHOD_CHANNEL_NAME = "frontegg_flutter" const val STATE_EVENT_CHANNEL_NAME = "frontegg_flutter/state_stream" + + /** + * E2E hook: kept here so the demo app's bootstrap code can re-attach the + * state observers after replacing the [com.frontegg.android.FronteggApp] + * singleton. Without this, the listener captured at plugin attach time + * remains subscribed to the dead instance and Flutter never sees state + * updates from the new (mock-server-bound) instance. + */ + @JvmStatic + var activeStateListener: FronteggStateListenerImpl? = null + internal set } } diff --git a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggState.kt b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggState.kt index 0e684cb3..bbfba0f9 100644 --- a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggState.kt +++ b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggState.kt @@ -12,6 +12,8 @@ data class FronteggState( val showLoader: Boolean, val appLink: Boolean, val refreshingToken: Boolean, + /** Mirrors iOS; Android SDK does not expose this yet — keep false until wired. */ + val isOfflineMode: Boolean = false, ) { fun toMap(): Map { return mapOf( @@ -24,6 +26,7 @@ data class FronteggState( Pair("showLoader", showLoader), Pair("appLink", appLink), Pair("refreshingToken", refreshingToken), + Pair("isOfflineMode", isOfflineMode), ) } @@ -37,7 +40,8 @@ data class FronteggState( initializing, appLink, showLoader, - refreshingToken + refreshingToken, + isOfflineMode, ) } @@ -53,6 +57,7 @@ data class FronteggState( initializing == state.initializing && appLink == state.appLink && showLoader == state.showLoader && - refreshingToken == state.refreshingToken; + refreshingToken == state.refreshingToken && + isOfflineMode == state.isOfflineMode; } } \ No newline at end of file diff --git a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt index 54edf8e2..bf444675 100644 --- a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt +++ b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt @@ -93,6 +93,7 @@ class FronteggStateListenerImpl( showLoader = fronteggAuth.showLoader.value, appLink = fronteggAuth.useAssetsLinks, refreshingToken = fronteggAuth.refreshingToken.value, + isOfflineMode = false, ) sendState(state) @@ -133,6 +134,7 @@ class FronteggStateListenerImpl( showLoader = fronteggAuth.showLoader.value, appLink = fronteggAuth.useAssetsLinks, refreshingToken = fronteggAuth.refreshingToken.value, + isOfflineMode = false, ) sendState(state) diff --git a/embedded/android/app/src/main/AndroidManifest.xml b/embedded/android/app/src/main/AndroidManifest.xml index 63368d2a..984960ec 100644 --- a/embedded/android/app/src/main/AndroidManifest.xml +++ b/embedded/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ android:allowBackup="true" android:supportsRtl="true" android:theme="@android:style/Theme.Light.NoTitleBar" + android:networkSecurityConfig="@xml/network_security_config" + android:usesCleartextTraffic="true" tools:targetApi="31"> writeBootstrap(call, result) + "consumeBootstrap" -> consumeBootstrap(result) + "initializeForE2E" -> initializeForE2E(call, result) + "resetForTesting" -> resetForTesting(result) + else -> result.notImplemented() + } + } + + private fun writeBootstrap(call: MethodCall, result: MethodChannel.Result) { + val baseUrl = call.argument("baseUrl") ?: return result.error("MISSING_PARAM", "baseUrl required", null) + val clientId = call.argument("clientId") ?: return result.error("MISSING_PARAM", "clientId required", null) + val resetState = call.argument("resetState") ?: true + val forceNetworkPathOffline = call.argument("forceNetworkPathOffline") ?: false + val enableOfflineMode = call.argument("enableOfflineMode") + + DemoEmbeddedTestMode.writeBootstrap( + context = context, + baseUrl = baseUrl, + clientId = clientId, + resetState = resetState, + forceNetworkPathOffline = forceNetworkPathOffline, + enableOfflineMode = enableOfflineMode, + ) + result.success(null) + } + + private fun consumeBootstrap(result: MethodChannel.Result) { + if (DemoEmbeddedTestMode.consumeBootstrapIfPresent(context)) { + result.success(mapOf( + "baseUrl" to DemoEmbeddedTestMode.baseUrl, + "clientId" to DemoEmbeddedTestMode.clientId, + "resetState" to DemoEmbeddedTestMode.resetState, + "forceNetworkPathOffline" to DemoEmbeddedTestMode.forceNetworkPathOffline, + "enableOfflineMode" to DemoEmbeddedTestMode.enableOfflineMode, + )) + } else { + result.success(null) + } + } + + private fun initializeForE2E(call: MethodCall, result: MethodChannel.Result) { + val baseUrl = call.argument("baseUrl") ?: return result.error("MISSING_PARAM", "baseUrl required", null) + val clientId = call.argument("clientId") ?: return result.error("MISSING_PARAM", "clientId required", null) + val resetState = call.argument("resetState") ?: true + val forceNetworkPathOffline = call.argument("forceNetworkPathOffline") ?: false + val enableOfflineMode = call.argument("enableOfflineMode") + + DemoEmbeddedTestMode.applyConfig( + baseUrl = baseUrl, + clientId = clientId, + resetState = resetState, + forceNetworkPathOffline = forceNetworkPathOffline, + enableOfflineMode = enableOfflineMode, + ) + + try { + FronteggE2eEmbeddedInitializer.rebindSingletonToMockServer(context, baseUrl, clientId) + result.success(null) + } catch (e: Exception) { + result.error("E2E_INIT_FAILED", e.message, null) + } + } + + private fun resetForTesting(result: MethodChannel.Result) { + try { + FronteggE2eEmbeddedInitializer.clearSingletonInstance() + DemoEmbeddedTestMode.reset() + result.success(null) + } catch (e: Exception) { + result.error("RESET_FAILED", e.message, null) + } + } +} diff --git a/embedded/android/app/src/main/kotlin/com/frontegg/demo/FronteggE2eEmbeddedInitializer.kt b/embedded/android/app/src/main/kotlin/com/frontegg/demo/FronteggE2eEmbeddedInitializer.kt new file mode 100644 index 00000000..6acb4b3f --- /dev/null +++ b/embedded/android/app/src/main/kotlin/com/frontegg/demo/FronteggE2eEmbeddedInitializer.kt @@ -0,0 +1,83 @@ +package com.frontegg.demo + +import android.content.Context +import com.frontegg.android.EmbeddedAuthActivity +import com.frontegg.android.services.FronteggAppService +import com.frontegg.android.utils.FronteggConstantsProvider +import com.frontegg.android.utils.SentryHelper +import com.frontegg.android.utils.isActivityEnabled +import com.frontegg.flutter.FronteggFlutterPlugin + +/** + * Mirrors [com.frontegg.android.FronteggApp.initializeEmbeddedForLocalE2E] for published SDKs + * that do not yet expose that API (e.g. Maven 1.3.24). + */ +object FronteggE2eEmbeddedInitializer { + + fun clearSingletonInstance() { + runCatching { + val companion = fronteggCompanion() + val f = companion.javaClass.getDeclaredField("instance").apply { isAccessible = true } + f.set(companion, null) + } + } + + fun rebindSingletonToMockServer(context: Context, baseUrl: String, clientId: String) { + clearSingletonInstance() + val appCtx = context.applicationContext + val normalized = if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + baseUrl + } else { + "https://$baseUrl" + } + val isEmbedded = appCtx.isActivityEnabled(EmbeddedAuthActivity::class.java.name) + runCatching { + val constants = FronteggConstantsProvider.fronteggConstants(appCtx) + SentryHelper.prepare(appCtx, constants) + } + val service = FronteggAppService( + context = appCtx, + baseUrl = normalized, + clientId = clientId, + applicationId = null, + isEmbeddedMode = isEmbedded, + handleLoginWithSocialLogin = true, + handleLoginWithSocialLoginProvider = true, + handleLoginWithCustomSocialLoginProvider = true, + handleLoginWithSSO = true, + shouldPromptSocialLoginConsent = false, + useAssetsLinks = false, + useChromeCustomTabs = false, + mainActivityClass = null, + deepLinkScheme = null, + useDiskCacheWebview = false, + disableAutoRefresh = false, + enableSessionPerTenant = false, + entitlementsEnabled = false, + tenantResolver = null, + ) + val companion = fronteggCompanion() + val f = companion.javaClass.getDeclaredField("instance").apply { isAccessible = true } + f.set(companion, service) + + // Re-attach the Flutter plugin's state listener to the new singleton. + // The listener captured observable references on the OLD instance at + // plugin attach time; without re-subscribing, Flutter would never see + // state updates from the mock-server-bound instance and the Flutter UI + // would stay stuck on LoginPageRoot. + runCatching { + FronteggFlutterPlugin.activeStateListener?.subscribe() + } + } + + private fun fronteggCompanion(): Any { + runCatching { + return Class.forName("com.frontegg.android.FronteggApp\$Companion") + .getDeclaredField("INSTANCE") + .apply { isAccessible = true } + .get(null)!! + } + val iface = Class.forName("com.frontegg.android.FronteggApp") + return iface.getDeclaredField("Companion").apply { isAccessible = true }.get(null)!! + } +} diff --git a/embedded/android/app/src/main/kotlin/com/frontegg/demo/MainActivity.kt b/embedded/android/app/src/main/kotlin/com/frontegg/demo/MainActivity.kt index 6dace1d0..89cf66e1 100644 --- a/embedded/android/app/src/main/kotlin/com/frontegg/demo/MainActivity.kt +++ b/embedded/android/app/src/main/kotlin/com/frontegg/demo/MainActivity.kt @@ -6,19 +6,23 @@ import android.os.Bundle import android.widget.ProgressBar import com.frontegg.android.ui.DefaultLoader import io.flutter.embedding.android.FlutterActivity - +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Setup Loader for Frontegg Embedded Activity Loading DefaultLoader.setLoaderProvider { val progressBar = ProgressBar(it) val colorStateList = ColorStateList.valueOf(Color.GREEN) progressBar.indeterminateTintList = colorStateList - progressBar } } + + override fun configureFlutterEngine(flutterEngine: io.flutter.embedding.engine.FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "frontegg_e2e") + .setMethodCallHandler(E2EMethodChannel(applicationContext)) + } } diff --git a/embedded/android/app/src/main/res/xml/network_security_config.xml b/embedded/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..3ccbe612 --- /dev/null +++ b/embedded/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 127.0.0.1 + localhost + 10.0.2.2 + + diff --git a/embedded/android/gradle.properties b/embedded/android/gradle.properties index aa5fda35..9e4ac0d5 100644 --- a/embedded/android/gradle.properties +++ b/embedded/android/gradle.properties @@ -21,4 +21,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableJetifier=true +android.enableJetifier=false diff --git a/embedded/e2e/scenario-catalog.json b/embedded/e2e/scenario-catalog.json new file mode 100644 index 00000000..fc4b6c0a --- /dev/null +++ b/embedded/e2e/scenario-catalog.json @@ -0,0 +1,114 @@ +{ + "tests": [ + { + "method": "testPasswordLoginAndSessionRestore", + "title": "Password login survives relaunch", + "description": "Logs in with the embedded password flow, relaunches the app without resetting state, and verifies the authenticated session is restored." + }, + { + "method": "testEmbeddedSamlLogin", + "title": "Embedded SAML login via mock Okta", + "description": "Triggers a SAML login hint, taps the mock Okta button in the embedded WebView, and verifies authentication." + }, + { + "method": "testEmbeddedOidcLogin", + "title": "Embedded OIDC login via mock Okta", + "description": "Triggers an OIDC login hint, taps the mock Okta button in the embedded WebView, and verifies authentication." + }, + { + "method": "testRequestAuthorizeFlow", + "title": "Request authorize with seeded refresh token", + "description": "Seeds a refresh token, triggers requestAuthorize, and verifies the session is established." + }, + { + "method": "testCustomSSOBrowserHandoff", + "title": "Custom SSO browser handoff completes login", + "description": "Initiates a custom SSO flow that opens a browser, mock server completes the handoff, and the user is authenticated." + }, + { + "method": "testDirectSocialBrowserHandoff", + "title": "Direct social browser handoff completes login", + "description": "Initiates a direct social login that opens a browser, mock server completes the handoff, and the user is authenticated." + }, + { + "method": "testEmbeddedGoogleSocialLoginWithSystemWebAuthenticationSession", + "title": "Google social login via system web auth session", + "description": "Triggers Google social login, the Custom Tab loads the mock Google page, auto-completes, and the user is authenticated." + }, + { + "method": "testEmbeddedGoogleSocialLoginOAuthErrorShowsToastAndKeepsLoginOpen", + "title": "Google social OAuth error shows toast", + "description": "Queues an OAuth error for the social callback, triggers Google social login, and verifies the error code appears in the UI." + }, + { + "method": "testColdLaunchTransientProbeTimeoutsDoNotBlinkNoConnectionPage", + "title": "Transient probe timeouts do not blink NoConnection", + "description": "Queues slow probe responses, cold-launches the app, and verifies no NoConnection overlay appears." + }, + { + "method": "testLogoutTerminateTransientProbeFailureDoesNotBlinkNoConnectionPage", + "title": "Post-logout probe failures do not blink NoConnection", + "description": "Logs in, logs out, queues probe failures, relaunches, and verifies no NoConnection overlay appears." + }, + { + "method": "testAuthenticatedOfflineModeWhenNetworkPathUnavailable", + "title": "Offline mode activates when network path unavailable", + "description": "Logs in, relaunches with forced offline, and verifies the offline mode badge and preserved token version." + }, + { + "method": "testExpiredAccessTokenRefreshesOnAuthenticatedRelaunch", + "title": "Expired access token refreshes on authenticated relaunch", + "description": "Logs in with short access TTL, waits for expiry, relaunches, and verifies the token is refreshed." + }, + { + "method": "testAuthenticatedOfflineModeRecoversToOnlineAndRefreshesToken", + "title": "Offline mode recovers to online and refreshes token", + "description": "Logs in, goes offline, then comes back online, and verifies the token version changes." + }, + { + "method": "testAuthenticatedOfflineModeKeepsUserLoggedInUntilReconnectRefreshesExpiredToken", + "title": "Offline mode keeps user logged in until reconnect refreshes expired token", + "description": "Logs in with short TTLs, goes offline, waits for access expiry, reconnects, and verifies token refresh." + }, + { + "method": "testLogoutTerminateTransientNoConnectionThenCustomSSORecovers", + "title": "NoConnection after logout recovers via custom SSO", + "description": "Logs in, logs out, queues probe failure, sees NoConnection, retries, then logs in via custom SSO." + }, + { + "method": "testColdLaunchWithOfflineModeDisabledReachesLoginQuickly", + "title": "Cold launch with offline mode disabled reaches login quickly", + "description": "Queues probe failures, launches with offline mode disabled, and verifies login page appears quickly." + }, + { + "method": "testOfflineModeDisabledPreservesSessionDuringConnectionLossAndRecovers", + "title": "Offline mode disabled preserves session during connection loss", + "description": "Logs in with offline mode disabled, queues a connection drop, relaunches, and verifies the session is preserved." + }, + { + "method": "testPasswordLoginWorksWithOfflineModeDisabled", + "title": "Password login works with offline mode disabled", + "description": "Launches with offline mode disabled and verifies password login still works." + }, + { + "method": "testLogoutClearsSessionAndRelaunchShowsLogin", + "title": "Logout clears session and relaunch shows login", + "description": "Logs in, logs out, relaunches, and verifies the login page is shown." + }, + { + "method": "testExpiredRefreshTokenClearsSessionAndShowsLogin", + "title": "Expired refresh token clears session and shows login", + "description": "Logs in with very short refresh TTL, waits for expiry, relaunches with reset, and verifies login page." + }, + { + "method": "testScheduledTokenRefreshFiresBeforeExpiry", + "title": "Scheduled token refresh fires before access token expiry", + "description": "Logs in with a 45s access TTL, waits ~35s, and verifies the SDK sent a refresh request." + }, + { + "method": "testAuthenticatedRelaunchWithExpiredAccessTokenAndFreshRefreshToken", + "title": "Relaunch with expired access token refreshes via valid refresh token", + "description": "Logs in with a very short access TTL, terminates after access expires but before refresh expires, relaunches, and verifies the token is refreshed." + } + ] +} diff --git a/embedded/integration_test/e2e/README.md b/embedded/integration_test/e2e/README.md new file mode 100644 index 00000000..dcfcd2f1 --- /dev/null +++ b/embedded/integration_test/e2e/README.md @@ -0,0 +1,100 @@ +# Embedded E2E (Flutter) — current state and webview-login blocker + +## What runs today + +| Platform | Tests | Why | +|---|---|---| +| iOS | `testColdLaunch*`, `testRequestAuthorizeFlow` | No webview required — they hit the mock server via the SDK's HTTP client only. `testRequestAuthorizeFlow` proves that `manualInit(baseUrl: )` correctly switches the SDK to the mock server. | +| Android | `testColdLaunch*` | Same — no webview required. Android SDK has no mock-server testing support at all (see below). | + +Everything else is excluded in `.github/workflows/demo-e2e.yml`. The exclusion list +is long but the reason is the same single architectural blocker. + +## The blocker: webview login against a localhost mock server + +`FronteggSwift`'s `CustomWebView` (and the equivalent Android WebView client) +**unconditionally blocks navigation to `localhost` / `127.0.0.1`** as a security +measure. The OAuth callback URL (`com.frontegg.demo://127.0.0.1/ios/oauth/callback`) +is rejected by `decidePolicyFor` before the custom-scheme handler can process it, +which kills every embedded login flow against a localhost mock server. + +In **frontegg-ios-swift master** (PR #243, commit `f6ffe22`) the SDK adds +`FronteggRuntime.isTesting` plus an `isAllowedTestingLoopbackURL` whitelist: +when `ProcessInfo.processInfo.environment["frontegg-testing"] == "true"`, the +WebView allows localhost URLs whose host matches the configured `baseUrl`. + +This pin (`frontegg-ios-swift @ f6ffe22`) is what `ios/frontegg_flutter/Package.swift` +pulls today. The SDK code that whitelists localhost is **already present in the build**. + +## Why we still can't enable webview tests + +`FronteggRuntime.isTesting` reads from `ProcessInfo.processInfo.environment`, +which on iOS is **a snapshot of the environment at process creation time**. +Calling `setenv("frontegg-testing", "true", 1)` from `AppDelegate.didFinishLaunchingWithOptions` +modifies the C runtime environment but is NOT visible to `ProcessInfo`. The SDK +sees `isTesting == false` and the localhost block stays active. + +The Swift demo-embedded-e2e target works around this by setting +`XCUIApplication.launchEnvironment` from `XCTestCase.setUp` **before** calling +`app.launch()`. The XCUITest harness then forks the app process with those env +vars merged into its `ProcessInfo`. **This is how every Frontegg testing env var +reaches the SDK** — there is no IPC, no plist file, no other channel. + +Patrol (the Flutter integration-test framework we use) also goes through XCUITest, +**but it provides no public hook to set `XCUIApplication.launchEnvironment` on the +app it launches.** The Patrol runner picks the device, builds an `xctestrun` bundle, +launches the app from `IOSAutomator.swift`, and exposes none of those steps to the +Dart test code or to user-supplied Swift hooks. Attempting to inject env vars via: + +- `setenv(...)` from `AppDelegate.didFinishLaunchingWithOptions` — **too late** + (`ProcessInfo` already snapshotted) +- `setenv(...)` from an Objective-C `+load` method — earlier than Swift, but the + pbxproj edits to add a new Obj-C source file to the Runner target broke the + Patrol test launch (10-minute "Test runner never began executing tests") +- Modifying `Frontegg.plist` — the SDK reads env vars, not the plist, for testing flags +- `--dart-define` — passes Dart compile defines, not iOS process env + +…all fail or break Patrol. The `LocalMockAuthServer.dart` mock server itself +works fine — `testRequestAuthorizeFlow` passes against it on every run, proving +the SDK's HTTP client is using the mock URL after `manualInit`. The only thing +that fails is the **WebView navigation policy**, which the SDK only relaxes for +the `frontegg-testing=true` env var that we cannot inject. + +## Paths forward (none implemented yet) + +1. **Drop Patrol, write native XCUITest in `RunnerUITests`.** Mirror + `frontegg-ios-swift/demo-embedded/demo-embedded-e2e` exactly. Pros: known to + work, full parity with the Swift reference. Cons: rewrites every test in + Swift, loses the Dart `LocalMockAuthServer` reuse, needs a separate test + process that can drive the Flutter UI semantically (XCUITest works against + accessibility identifiers, which we already emit, so this is doable). + +2. **Patch Patrol to expose `launchEnvironment`.** Send a PR upstream adding a + way to set env vars from Dart that the iOS automator passes to + `XCUIApplication.launchEnvironment` before launch. Pros: keeps the Dart test + code and mock server. Cons: depends on upstream review/release. + +3. **Patch FronteggSwift to read the testing flag from a different source.** + E.g. read it from `Frontegg.plist` or from a UserDefaults key set by the + Dart side via method channel before any FronteggApp code runs. Pros: no + Patrol changes. Cons: requires upstream SDK PR that's specifically for + the Flutter use case. + +4. **Add an Android equivalent first.** The Android SDK has zero E2E testing + infrastructure (no mock server, no isTesting flag, no localhost override, + no manualInit). Whatever pattern we land on for iOS, the Android side needs + the same upstream work — there is no Android workaround that doesn't touch + the Frontegg Android SDK. + +## What this directory contains today + +- `local_mock_auth_server.dart` — Dart `HttpServer` mock with the same routes + as `LocalMockAuthServer.swift`. Works correctly — verified by + `testRequestAuthorizeFlow` passing. +- `embedded_e2e_test_case.dart` — test harness mirroring + `DemoEmbeddedUITestCase.swift` (waitForScreen helpers, login helpers, etc.). +- `embedded_e2e_test.dart` — test definitions; the webview-dependent ones are + excluded in CI via `INPUT_EXCLUDE_METHODS` in the workflow. + +The full test set is kept compiling so that whoever picks up paths (1)–(4) +above doesn't have to rewrite the test bodies. diff --git a/embedded/integration_test/e2e/embedded_e2e_test.dart b/embedded/integration_test/e2e/embedded_e2e_test.dart new file mode 100644 index 00000000..152d6823 --- /dev/null +++ b/embedded/integration_test/e2e/embedded_e2e_test.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'embedded_e2e_test_case.dart'; + +Finder _semFinder(String label) => find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == label, + description: 'Semantics(label: "$label")', + ); + +const _e2eTestFilter = String.fromEnvironment('E2E_TEST_FILTER'); + +/// When set (e.g. CI shards), only listed tests are registered. Supports a single +/// name or a comma-separated list so one `patrol test` run can execute a whole shard +/// without rebuilding the integration test target four times. +bool _e2eFilterAllows(String name) { + if (_e2eTestFilter.isEmpty) return true; + for (final raw in _e2eTestFilter.split(',')) { + final part = raw.trim(); + if (part.isEmpty) continue; + if (part == name) return true; + } + return false; +} + +void e2ePatrolTest(String name, PatrolTesterCallback callback) { + if (!_e2eFilterAllows(name)) return; + patrolTest(name, callback); +} + +/// Full embedded E2E suite mirroring Swift `DemoEmbeddedE2ETests` and +/// Kotlin `EmbeddedE2ETests`. +void main() { + final tc = EmbeddedE2ETestCase(); + + const expiringAccessTokenTTL = 21; + const longLivedRefreshTokenTTL = 120; + + patrolSetUp(() async { + await tc.setUp(); + }); + + patrolTearDown(() async { + await tc.tearDown(); + }); + + e2ePatrolTest('testPasswordLoginAndSessionRestore', ($) async { + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.launchApp($, resetState: false); + await Future.delayed(const Duration(milliseconds: 1500)); + await tc.waitForUserEmail($, 'test@frontegg.com', timeout: const Duration(seconds: 180)); + }); + + e2ePatrolTest('testEmbeddedSamlLogin', ($) async { + await tc.launchApp($); + await tc.waitForLoginPage($); + await tc.tapSemantics($, 'E2EEmbeddedSAMLButton'); + await tc.tapWebButtonIfPresent($, 'Login With Okta'); + await tc.waitForUserEmail( + $, + 'test@saml-domain.com', + timeout: const Duration(seconds: 90), + awaitingUserPageAfterEmbeddedWebView: true, + ); + }); + + e2ePatrolTest('testEmbeddedOidcLogin', ($) async { + await tc.launchApp($); + await tc.waitForLoginPage($); + await tc.tapSemantics($, 'E2EEmbeddedOIDCButton'); + await tc.tapWebButtonIfPresent($, 'Login With Okta'); + await tc.waitForUserEmail( + $, + 'test@oidc-domain.com', + timeout: const Duration(seconds: 90), + awaitingUserPageAfterEmbeddedWebView: true, + ); + }); + + e2ePatrolTest('testRequestAuthorizeFlow', ($) async { + await tc.launchApp($); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 35)); + await tc.tapSemantics($, 'E2ESeedRequestAuthorizeTokenButton'); + await Future.delayed(const Duration(seconds: 2)); + await tc.tapSemantics($, 'RequestAuthorizeButton'); + await tc.waitForUserEmail($, 'signup@frontegg.com', timeout: const Duration(seconds: 120)); + }); + + e2ePatrolTest('testCustomSSOBrowserHandoff', ($) async { + await tc.launchApp($); + await tc.waitForLoginPage($); + await tc.tapSemantics($, 'E2ECustomSSOButton'); + await Future.delayed(const Duration(milliseconds: 4500)); + await tc.waitForUserEmail( + $, + 'custom-sso@frontegg.com', + timeout: const Duration(seconds: 60), + awaitingUserPageAfterEmbeddedWebView: true, + ); + }); + + e2ePatrolTest('testDirectSocialBrowserHandoff', ($) async { + await tc.launchApp($); + await tc.waitForLoginPage($); + await tc.tapSemantics($, 'E2EDirectSocialLoginButton'); + await Future.delayed(const Duration(seconds: 6)); + await tc.waitForUserEmail( + $, + 'social-login@frontegg.com', + timeout: const Duration(seconds: 90), + awaitingUserPageAfterEmbeddedWebView: true, + ); + }); + + e2ePatrolTest('testEmbeddedGoogleSocialLoginWithSystemWebAuthenticationSession', ($) async { + await tc.launchApp($); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 40)); + await tc.tapSemantics($, 'E2EEmbeddedGoogleSocialButton'); + await Future.delayed(const Duration(seconds: 32)); + await tc.waitForUserEmail( + $, + 'google-social@frontegg.com', + timeout: const Duration(seconds: 150), + awaitingUserPageAfterEmbeddedWebView: true, + ); + }); + + e2ePatrolTest('testEmbeddedGoogleSocialLoginOAuthErrorShowsToastAndKeepsLoginOpen', ($) async { + tc.mock.queueEmbeddedSocialSuccessOAuthError( + 'ER-05001', + 'JWT token size exceeded the maximum allowed size. Please contact support to reduce token payload size.', + ); + await tc.launchApp($); + await tc.waitForLoginPage($); + await tc.tapSemantics($, 'E2EEmbeddedGoogleSocialButton'); + await Future.delayed(const Duration(seconds: 24)); + final found = await tc.waitForA11yTextContains( + $, + 'ER-05001', + timeout: const Duration(seconds: 65), + ); + expect(found, isTrue, reason: 'Expected error text in UI'); + }); + + e2ePatrolTest('testColdLaunchTransientProbeTimeoutsDoNotBlinkNoConnectionPage', ($) async { + tc.mock.queueProbeTimeouts(count: 2); + await tc.launchApp($); + await tc.waitForLoginPage($); + await Future.delayed(const Duration(milliseconds: 2100)); + expect( + _semFinder('NoConnectionPageRoot').evaluate().isEmpty, + isTrue, + reason: 'Unexpected NoConnection overlay', + ); + }); + + e2ePatrolTest('testLogoutTerminateTransientProbeFailureDoesNotBlinkNoConnectionPage', ($) async { + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.tapLogout($); + await tc.waitForLoginPage($); + tc.mock.queueProbeFailures([503, 503]); + await tc.launchApp($, resetState: false); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 50)); + await Future.delayed(const Duration(milliseconds: 3500)); + }); + + e2ePatrolTest('testAuthenticatedOfflineModeWhenNetworkPathUnavailable', ($) async { + await tc.launchApp($); + await tc.loginWithPassword($); + final initialVersion = await tc.accessTokenVersion($); + await tc.launchApp($, resetState: false, forceNetworkPathOffline: true); + await tc.waitForUserEmail( + $, + 'test@frontegg.com', + timeout: const Duration(seconds: 90), + ); + await tc.waitForSemantics($, 'AuthenticatedOfflineModeEnabled', timeout: const Duration(seconds: 10)); + await tc.waitForSemantics($, 'OfflineModeBadge', timeout: const Duration(seconds: 10)); + expect(await tc.accessTokenVersion($), equals(initialVersion)); + expect( + _semFinder('RetryConnectionButton').evaluate().isEmpty, + isTrue, + reason: 'Did not expect Retry button', + ); + }); + + e2ePatrolTest('testExpiredAccessTokenRefreshesOnAuthenticatedRelaunch', ($) async { + tc.mock.configureTokenPolicy( + email: 'test@frontegg.com', + accessTTL: expiringAccessTokenTTL, + refreshTTL: longLivedRefreshTokenTTL, + ); + await tc.launchApp($); + await tc.loginWithPassword($); + final v0 = await tc.accessTokenVersion($); + final rc0 = tc.oauthRefreshRequestCount(); + await tc.waitDurationSeconds(expiringAccessTokenTTL + 6); + await tc.launchApp($, resetState: false); + await tc.waitForUserEmail($, 'test@frontegg.com', timeout: const Duration(seconds: 120)); + await tc.waitForAccessTokenVersionChange($, v0, timeout: const Duration(seconds: 150)); + expect(tc.oauthRefreshRequestCount(), greaterThan(rc0)); + }); + + e2ePatrolTest('testAuthenticatedOfflineModeRecoversToOnlineAndRefreshesToken', ($) async { + await tc.launchApp($); + await tc.loginWithPassword($); + final v0 = await tc.accessTokenVersion($); + await tc.launchApp($, resetState: false, forceNetworkPathOffline: true); + await tc.waitForUserEmail($, 'test@frontegg.com', timeout: const Duration(seconds: 120)); + await tc.waitForSemantics($, 'OfflineModeBadge', timeout: const Duration(seconds: 75)); + await tc.launchApp($, resetState: false); + await tc.waitForUserEmail($, 'test@frontegg.com', timeout: const Duration(seconds: 120)); + await Future.delayed(const Duration(seconds: 18)); + await tc.waitForAccessTokenVersionChange($, v0, timeout: const Duration(seconds: 240)); + }); + + e2ePatrolTest('testAuthenticatedOfflineModeKeepsUserLoggedInUntilReconnectRefreshesExpiredToken', ($) async { + tc.mock.configureTokenPolicy( + email: 'test@frontegg.com', + accessTTL: expiringAccessTokenTTL, + refreshTTL: longLivedRefreshTokenTTL, + ); + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.launchApp($, resetState: false, forceNetworkPathOffline: true); + await tc.waitForUserEmail($, 'test@frontegg.com'); + await tc.waitForSemantics($, 'OfflineModeBadge', timeout: const Duration(seconds: 30)); + await tc.waitDurationSeconds(expiringAccessTokenTTL + 2); + await tc.waitForSemantics($, 'AuthenticatedOfflineModeEnabled', timeout: const Duration(seconds: 10)); + final versionBeforeReconnect = await tc.accessTokenVersion($); + await tc.launchApp($, resetState: false); + await tc.waitForUserEmail($, 'test@frontegg.com'); + await tc.waitForAccessTokenVersionChange($, versionBeforeReconnect, timeout: const Duration(seconds: 75)); + }); + + e2ePatrolTest('testLogoutTerminateTransientNoConnectionThenCustomSSORecovers', ($) async { + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.tapLogout($); + await tc.waitForLoginPage($); + tc.mock.queueProbeFailures([503]); + await tc.launchApp($, resetState: false); + await tc.waitForSemantics($, 'NoConnectionPageRoot', timeout: const Duration(seconds: 100)); + tc.mock.reset(); + await tc.tapSemantics($, 'RetryConnectionButton'); + await tc.tapSemantics($, 'E2ECustomSSOButton'); + await Future.delayed(const Duration(milliseconds: 5500)); + await tc.waitForUserEmail( + $, + 'custom-sso@frontegg.com', + timeout: const Duration(seconds: 90), + awaitingUserPageAfterEmbeddedWebView: true, + ); + }); + + e2ePatrolTest('testColdLaunchWithOfflineModeDisabledReachesLoginQuickly', ($) async { + tc.mock.queueProbeFailures([503, 503]); + await tc.launchApp($, enableOfflineMode: false); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 50)); + await Future.delayed(const Duration(milliseconds: 3500)); + }); + + e2ePatrolTest('testOfflineModeDisabledPreservesSessionDuringConnectionLossAndRecovers', ($) async { + await tc.launchApp($, enableOfflineMode: false); + await tc.loginWithPassword($); + tc.mock.queueConnectionDrops(path: '/oauth/token'); + await tc.launchApp($, resetState: false, enableOfflineMode: false); + await tc.waitForUserEmail($, 'test@frontegg.com'); + await Future.delayed(const Duration(seconds: 2)); + tc.mock.reset(); + }); + + e2ePatrolTest('testPasswordLoginWorksWithOfflineModeDisabled', ($) async { + await tc.launchApp($, enableOfflineMode: false); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 50)); + await tc.loginWithPassword( + $, + emailTimeout: const Duration(seconds: 90), + ); + }); + + e2ePatrolTest('testLogoutClearsSessionAndRelaunchShowsLogin', ($) async { + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.tapLogout($); + await tc.waitForLoginPage($); + await tc.launchApp($, resetState: false); + await Future.delayed(const Duration(milliseconds: 1500)); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 60)); + }); + + e2ePatrolTest('testExpiredRefreshTokenClearsSessionAndShowsLogin', ($) async { + tc.mock.configureTokenPolicy(email: 'test@frontegg.com', accessTTL: 30, refreshTTL: 12); + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.waitDurationSeconds(18); + await tc.launchApp($); + await Future.delayed(const Duration(milliseconds: 2500)); + await tc.waitForLoginPage($, timeout: const Duration(seconds: 60)); + }); + + e2ePatrolTest('testScheduledTokenRefreshFiresBeforeExpiry', ($) async { + tc.mock.configureTokenPolicy( + email: 'test@frontegg.com', + accessTTL: 45, + refreshTTL: longLivedRefreshTokenTTL, + ); + await tc.launchApp($); + await tc.loginWithPassword($); + final start = tc.oauthRefreshRequestCount(); + var sawRefresh = false; + final deadline = DateTime.now().add(const Duration(seconds: 70)); + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(seconds: 3)); + if (tc.oauthRefreshRequestCount() > start) { + sawRefresh = true; + break; + } + } + expect(sawRefresh, isTrue, reason: 'Expected OAuth refresh before 45s access TTL on mock policy'); + }); + + e2ePatrolTest('testAuthenticatedRelaunchWithExpiredAccessTokenAndFreshRefreshToken', ($) async { + tc.mock.configureTokenPolicy( + email: 'test@frontegg.com', + accessTTL: expiringAccessTokenTTL, + refreshTTL: longLivedRefreshTokenTTL, + ); + await tc.launchApp($); + await tc.loginWithPassword($); + await tc.waitDurationSeconds(expiringAccessTokenTTL + 8); + await tc.launchApp($, resetState: false); + await tc.waitForUserEmail($, 'test@frontegg.com', timeout: const Duration(seconds: 180)); + }); +} diff --git a/embedded/integration_test/e2e/embedded_e2e_test_case.dart b/embedded/integration_test/e2e/embedded_e2e_test_case.dart new file mode 100644 index 00000000..1a2eb7a9 --- /dev/null +++ b/embedded/integration_test/e2e/embedded_e2e_test_case.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontegg_flutter_embedded_example/e2e_test_mode.dart'; +import 'package:frontegg_flutter_embedded_example/main.dart'; +import 'package:patrol/patrol.dart'; + +import 'local_mock_auth_server.dart'; + +/// Finds a [Semantics] widget whose [SemanticsProperties.label] equals [label]. +/// +/// Unlike [find.bySemanticsLabel], this does NOT rely on the compiled semantics +/// tree (RenderObject.debugSemantics), which may be null when the +/// sendSemanticsUpdate frame phase has not yet completed — a common situation +/// in [LiveTestWidgetsFlutterBinding] (integration tests). +Finder _semFinder(String label) { + return find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == label, + description: 'Semantics(label: "$label")', + ); +} + +/// Base harness for embedded mock-server E2E tests (Swift/Kotlin parity). +/// +/// Provides lifecycle helpers, mock server management, and UI interaction +/// utilities that mirror `EmbeddedE2ETestCase` on both native platforms. +class EmbeddedE2ETestCase { + late LocalMockAuthServer mock; + + Future setUp() async { + mock = LocalMockAuthServer(); + await mock.start(); + } + + Future tearDown() async { + await mock.shutdown(); + } + + Future launchApp( + PatrolIntegrationTester $, { + bool resetState = true, + bool forceNetworkPathOffline = false, + bool? enableOfflineMode, + }) async { + await E2ETestMode.initializeForE2E( + baseUrl: mock.urlRoot, + clientId: mock.clientId, + resetState: resetState, + forceNetworkPathOffline: forceNetworkPathOffline, + enableOfflineMode: enableOfflineMode, + ); + + await $.pumpWidget(const MyApp()); + await $.pump(); + + // Poll until the SDK finishes initializing and the widget tree updates. + // Future.delayed alone does not advance the test binding; bare pump() applies + // one frame (safe during cold launch). Avoid pump(Duration), which can stall + // when a modal webview is presented later in the flow. + final deadline = DateTime.now().add(const Duration(seconds: 30)); + var settled = false; + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 300)); + await $.pump(); + if (_semFinder('LoginPageRoot').evaluate().isNotEmpty || + _semFinder('UserPageRoot').evaluate().isNotEmpty) { + settled = true; + break; + } + } + if (!settled) { + throw AssertionError( + 'launchApp: UI not ready after 30s — baseUrl=${mock.urlRoot}', + ); + } + } + + Future waitForLoginPage(PatrolIntegrationTester $, {Duration timeout = const Duration(seconds: 20)}) async { + await waitForSemantics($, 'LoginPageRoot', timeout: timeout); + } + + /// When [awaitingUserPageAfterEmbeddedWebView] is true, skips [WidgetTester.pump] + /// while waiting for [UserPageRoot] — pump can deadlock while a native embedded + /// WebView is modal. Use false for in-app flows (e.g. [FronteggFlutter.requestAuthorize]). + Future waitForUserEmail( + PatrolIntegrationTester $, + String email, { + Duration timeout = const Duration(seconds: 30), + bool awaitingUserPageAfterEmbeddedWebView = false, + }) async { + await waitForSemantics( + $, + 'UserPageRoot', + timeout: timeout, + pumpFrame: !awaitingUserPageAfterEmbeddedWebView, + ); + // Once UserPageRoot is present, the embedded WebView is gone; pump is needed + // so the subtree (e.g. email Text) is built — skipping pump here caused CI timeouts. + await Future.delayed(const Duration(milliseconds: 400)); + await waitForText($, email, timeout: timeout); + } + + Future tapSemantics(PatrolIntegrationTester $, String label, {Duration timeout = const Duration(seconds: 10)}) async { + await waitForSemantics($, label, timeout: timeout); + final finder = _semFinder(label).first; + await $.tester.ensureVisible(finder); + await Future.delayed(const Duration(milliseconds: 300)); + await $.pump(); + await $.tester.tap(finder); + // Do not pump() after tap: embedded login can present a native webview + // immediately; WidgetTester.pump then blocks and never returns, so job hits CI timeout. + await Future.delayed(const Duration(milliseconds: 500)); + } + + Future loginWithPassword( + PatrolIntegrationTester $, { + Duration emailTimeout = const Duration(seconds: 60), + }) async { + await waitForLoginPage($); + await tapSemantics($, 'E2EEmbeddedPasswordButton'); + await waitForUserEmail( + $, + 'test@frontegg.com', + timeout: emailTimeout, + awaitingUserPageAfterEmbeddedWebView: true, + ); + } + + Future tapLogout(PatrolIntegrationTester $) async { + await tapSemantics($, 'LogoutButton'); + } + + int oauthRefreshRequestCount() { + const paths = ['/oauth/token', '/frontegg/identity/resources/auth/v1/user/token/refresh']; + return paths.fold(0, (sum, p) => sum + mock.requestCount(null, p)); + } + + Future accessTokenVersion(PatrolIntegrationTester $, {Duration timeout = const Duration(seconds: 10)}) async { + await waitForSemantics($, 'AccessTokenVersionValue', timeout: timeout); + final sem = _semFinder('AccessTokenVersionValue').evaluate().first; + final textFinder = find.descendant(of: find.byWidget(sem.widget), matching: find.byType(Text)); + if (textFinder.evaluate().isNotEmpty) { + final textWidget = textFinder.evaluate().first.widget; + if (textWidget is Text) { + return int.tryParse(textWidget.data ?? '0') ?? 0; + } + } + return 0; + } + + Future waitForAccessTokenVersionChange( + PatrolIntegrationTester $, + int from, { + Duration timeout = const Duration(seconds: 30), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 350)); + try { + final v = await accessTokenVersion($, timeout: const Duration(seconds: 2)); + if (v != from) return v; + } catch (_) {} + } + throw AssertionError('Token version did not change from $from'); + } + + Future waitForA11yTextContains(PatrolIntegrationTester $, String fragment, {Duration timeout = const Duration(seconds: 30)}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 250)); + // No pump(): same embedded WebView / Custom Tab deadlock as UserPageRoot wait. + final found = find.textContaining(fragment).evaluate().isNotEmpty; + if (found) return true; + } + return false; + } + + Future waitDurationSeconds(int seconds) async { + await Future.delayed(Duration(seconds: seconds)); + } + + Future waitForSemantics( + PatrolIntegrationTester $, + String label, { + Duration timeout = const Duration(seconds: 20), + bool pumpFrame = true, + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 250)); + if (pumpFrame) await $.pump(); + if (_semFinder(label).evaluate().isNotEmpty) return; + } + // Collect diagnostic info about which semantic labels ARE visible. + final allSemantics = []; + for (final candidate in ['LoginPageRoot', 'UserPageRoot', 'AuthenticatedOfflineRoot', 'NoConnectionPageRoot']) { + if (_semFinder(candidate).evaluate().isNotEmpty) allSemantics.add(candidate); + } + throw AssertionError( + 'Timeout waiting for semantics label=$label ' + '(visible: ${allSemantics.isEmpty ? "none" : allSemantics.join(", ")})', + ); + } + + /// When [pumpFrame] is false, only real-time delays run — avoids deadlocks when + /// [WidgetTester.pump] would block while a native embedded WebView / Custom Tab is up. + Future waitForText( + PatrolIntegrationTester $, + String text, { + Duration timeout = const Duration(seconds: 20), + bool pumpFrame = true, + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 250)); + if (pumpFrame) await $.pump(); + if (find.text(text).evaluate().isNotEmpty) return; + } + throw AssertionError('Timeout waiting for text=$text'); + } + + Future tapWebButtonIfPresent(PatrolIntegrationTester $, String text, {Duration timeout = const Duration(seconds: 20)}) async { + await Future.delayed(const Duration(seconds: 3)); + try { + await $.native.tap(Selector(text: text), timeout: timeout); + } catch (_) { + try { + await $.native.tap(Selector(textContains: text), timeout: const Duration(seconds: 5)); + } catch (_) { + // Button not found — likely already auto-submitted (e.g. password + // login with prefilled credentials). This is expected behaviour. + } + } + } +} diff --git a/embedded/integration_test/e2e/local_mock_auth_server.dart b/embedded/integration_test/e2e/local_mock_auth_server.dart new file mode 100644 index 00000000..5f74a200 --- /dev/null +++ b/embedded/integration_test/e2e/local_mock_auth_server.dart @@ -0,0 +1,1022 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Dart mirror of the Swift/Kotlin `LocalMockAuthServer` for embedded E2E. +class LocalMockAuthServer { + static const int defaultPort = 49384; + + final String clientId = 'demo-embedded-e2e-client'; + late final HttpServer _server; + late final String urlRoot; + final _state = _MockAuthState(); + final _requestLog = <_LoggedRequest>[]; + + Future start({int port = defaultPort}) async { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + urlRoot = 'http://127.0.0.1:${_server.port}'; + _server.listen(_handleRequest); + } + + Future shutdown() async { + await _server.close(force: true); + } + + void reset() { + _state.reset(); + _requestLog.clear(); + } + + void configureTokenPolicy({ + required String email, + required int accessTTL, + required int refreshTTL, + int startingTokenVersion = 1, + }) { + _state.configureTokenPolicy( + email: email, + accessTTL: accessTTL, + refreshTTL: refreshTTL, + startingTokenVersion: startingTokenVersion, + ); + } + + void queueProbeFailures(List statusCodes) { + _state.enqueue( + method: 'HEAD', + path: '/test', + responses: statusCodes.map((s) => {'status': s, 'body': 'offline'}).toList(), + ); + } + + void queueProbeTimeouts({required int count, int delayMs = 1500}) { + _state.enqueue( + method: 'HEAD', + path: '/test', + responses: List.generate(count, (_) => {'status': 200, 'body': 'ok', 'delay_ms': delayMs}), + ); + } + + void queueConnectionDrops({String method = 'POST', required String path, int count = 1}) { + _state.enqueue( + method: method, + path: path, + responses: List.generate(count, (_) => {'close_connection': true}), + ); + } + + void queueEmbeddedSocialSuccessOAuthError(String errorCode, String errorDescription) { + _state.queueEmbeddedSocialSuccessOAuthError(errorCode, errorDescription); + } + + int requestCount(String? method, String path) { + return _requestLog.where((r) => r.path == path && (method == null || r.method == method)).length; + } + + // --------------------------------------------------------------------------- + // Request handling + // --------------------------------------------------------------------------- + + Future _handleRequest(HttpRequest request) async { + final method = request.method.toUpperCase(); + final path = _normalizePath(request.uri.path); + final query = request.uri.queryParametersAll; + final headers = {}; + request.headers.forEach((name, values) { + headers[name.toLowerCase()] = values.join(', '); + }); + final bodyBytes = await request.fold>([], (prev, chunk) => prev..addAll(chunk)); + + _requestLog.add(_LoggedRequest(method: method, path: path)); + + // Debug logging for CI: trace mock-server requests to diagnose login flow failures. + stderr.writeln('[MockServer] $method $path${request.uri.hasQuery ? '?${request.uri.query}' : ''}'); + + final queued = _state.dequeue(method: method, path: path); + if (queued != null) { + await _sendQueuedResponse(request.response, queued, method); + return; + } + + final key = '$method $path'; + switch (key) { + case 'HEAD /test': + case 'GET /test': + _sendText(request.response, 200, 'ok'); + case 'GET /oauth/authorize': + _renderAuthorizePage(request.response, query); + case 'GET /oauth/account/social/success': + _handleSocialLoginSuccess(request.response, query); + case 'GET /oauth/prelogin': + _handleHostedPrelogin(request.response, query); + case 'POST /oauth/postlogin': + _handleHostedPostlogin(request.response, bodyBytes); + case 'GET /oauth/postlogin/redirect': + _handleHostedPostloginRedirect(request.response, query); + case 'GET /idp/google/authorize': + _handleMockGoogleAuthorize(request.response, query); + case 'GET /embedded/continue': + _renderEmbeddedContinue(request.response, query); + case 'POST /embedded/password': + _completeEmbeddedPassword(request.response, bodyBytes); + case 'GET /browser/complete': + _completeBrowserFlow(request.response, query); + case 'GET /dashboard': + _sendHtml(request.response, 200, 'Dashboard', '

Dashboard

'); + case 'POST /oauth/token': + _handleOAuthToken(request.response, bodyBytes); + case 'POST /frontegg/oauth/authorize/silent': + _handleSilentAuthorize(request.response, headers); + case 'GET /flags': + case 'GET /frontegg/flags': + _sendJson(request.response, 200, {'mobile-enable-logging': 'off'}); + case 'GET /frontegg/metadata': + _sendJson(request.response, 200, {'appName': 'demo-embedded-e2e', 'environment': 'local'}); + case 'GET /vendors/public': + case 'GET /frontegg/vendors/public': + _sendJson(request.response, 200, {'vendors': []}); + case 'GET /frontegg/identity/resources/sso/v2': + _handleSocialLoginConfig(request.response); + case 'GET /frontegg/identity/resources/configurations/v1/public': + _sendJson(request.response, 200, {'embeddedMode': true, 'loginBoxVisible': true}); + case 'GET /frontegg/identity/resources/configurations/v1/auth/strategies/public': + _sendJson(request.response, 200, {'password': true, 'socialLogin': true, 'sso': true}); + case 'GET /frontegg/identity/resources/configurations/v1/sign-up/strategies': + _sendJson(request.response, 200, {'allowSignUp': true}); + case 'GET /frontegg/team/resources/sso/v2/configurations/public': + _sendJson(request.response, 200, []); + case 'GET /identity/resources/sso/custom/v1': + case 'GET /frontegg/identity/resources/sso/custom/v1': + _sendJson(request.response, 200, {'providers': []}); + case 'GET /identity/resources/configurations/sessions/v1': + _sendJson(request.response, 200, {'cookieName': 'fe_refresh_demo_embedded_e2e', 'keepSessionAlive': true}); + case 'GET /frontegg/identity/resources/configurations/v1/captcha-policy/public': + _sendJson(request.response, 200, {'enabled': false}); + case 'POST /frontegg/identity/resources/auth/v1/user/token/refresh': + _handleHostedRefresh(request.response, headers); + case 'POST /frontegg/identity/resources/auth/v2/user/sso/prelogin': + _handleHostedSSOPrelogin(request.response, bodyBytes); + case 'POST /frontegg/identity/resources/auth/v1/user': + _handleHostedPasswordLogin(request.response, bodyBytes); + case 'GET /identity/resources/users/v2/me': + _handleMe(request.response, headers); + case 'GET /identity/resources/users/v3/me/tenants': + _handleTenants(request.response, headers); + case 'POST /oauth/logout/token': + _handleLogout(request.response, headers); + default: + stderr.writeln('[MockServer] ⚠️ 404 Unhandled: $method $path'); + _sendJson(request.response, 404, {'error': 'Unhandled route $method $path'}); + } + } + + // --------------------------------------------------------------------------- + // Route handlers + // --------------------------------------------------------------------------- + + void _renderAuthorizePage(HttpResponse res, Map> query) { + final redirectUri = _fv(query, 'redirect_uri'); + final stateValue = _fv(query, 'state'); + final clientId = _fv(query, 'client_id'); + final loginAction = _fv(query, 'login_direct_action'); + final loginHint = _fv(query, 'login_hint'); + + if (loginAction.isNotEmpty) { + final decoded = _decodeBase64UrlJson(loginAction) ?? {}; + final destination = decoded['data'] as String? ?? ''; + String title, buttonTitle, email; + if (destination.contains('custom-sso')) { + title = 'Custom SSO Mock Server'; + buttonTitle = 'Continue to Custom SSO'; + email = 'custom-sso@frontegg.com'; + } else if (destination.contains('mock-social-provider')) { + title = 'Mock Social Login'; + buttonTitle = 'Continue with Mock Social'; + email = 'social-login@frontegg.com'; + } else { + title = 'Direct Login Mock Server'; + buttonTitle = 'Continue'; + email = 'direct-login@frontegg.com'; + } + final body = ''' +

${_he(title)}

+
+ + + + +
'''; + _sendHtml(res, 200, title, body); + return; + } + + final hostedState = _state.issueHostedLoginContext( + redirectUri: redirectUri, + originalState: stateValue, + loginHint: loginHint, + ); + + final preloginUri = Uri.parse('$urlRoot/oauth/prelogin').replace( + queryParameters: { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'state': hostedState, + if (loginHint.isNotEmpty) 'login_hint': loginHint, + }, + ); + _sendRedirect(res, preloginUri.toString()); + } + + void _handleHostedPrelogin(HttpResponse res, Map> query) { + final hostedState = _fv(query, 'state'); + final context = _state.hostedLoginContext(hostedState); + if (context == null) { + _sendHtml(res, 400, 'Invalid hosted flow', '

Invalid hosted flow

'); + return; + } + + var email = _fv(query, 'email'); + if (email.isEmpty) email = context.loginHint; + + if (email.isEmpty) { + _renderHostedEmailStep(res, hostedState); + return; + } + + if (email.endsWith('@saml-domain.com')) { + _renderHostedProviderStep(res, title: 'OKTA SAML Mock Server', buttonTitle: 'Login With Okta', hostedState: hostedState, email: email); + return; + } + + if (email.endsWith('@oidc-domain.com')) { + _renderHostedProviderStep(res, title: 'OKTA OIDC Mock Server', buttonTitle: 'Login With Okta', hostedState: hostedState, email: email); + return; + } + + _renderHostedPasswordStep(res, hostedState: hostedState, email: email, prefilledPassword: context.loginHint.isNotEmpty); + } + + void _renderHostedEmailStep(HttpResponse res, String hostedState) { + final body = ''' +

Mock Embedded Login

+
+ + + +
+
+ + +
+${_hostedBootstrapScript(true)}'''; + _sendHtml(res, 200, 'Mock Embedded Login', body); + } + + void _renderHostedPasswordStep(HttpResponse res, {required String hostedState, required String email, required bool prefilledPassword}) { + final passwordValue = prefilledPassword ? ' value="Testpassword1!"' : ''; + final hostedStateLit = _jsLiteral(hostedState); + final emailLit = _jsLiteral(email); + final autoSubmit = prefilledPassword ? ''' +window.addEventListener('load', () => { + setTimeout(() => { if (passwordField.value) form.requestSubmit(); }, 350); +});''' : ''; + final body = ''' +

Password Login

+
+ + + +

+
+'''; + _sendHtml(res, 200, 'Password Login', body); + } + + void _renderHostedProviderStep(HttpResponse res, {required String title, required String buttonTitle, required String hostedState, required String email}) { + final policy = _state.tokenPolicy(email); + final accessTok = _accessToken(email: email, tokenVersion: policy.startingTokenVersion, expiresIn: policy.accessTTL); + final body = ''' +

${_he(title)}

+
+ +

+
+'''; + _sendHtml(res, 200, title, body); + } + + String _hostedBootstrapScript(bool includeRefresh) { + const urls = [ + '/vendors/public', '/frontegg/metadata', '/flags', '/frontegg/flags', + '/frontegg/identity/resources/sso/v2', '/frontegg/identity/resources/configurations/v1/public', + '/frontegg/identity/resources/configurations/v1/auth/strategies/public', + '/frontegg/identity/resources/configurations/v1/sign-up/strategies', + '/frontegg/team/resources/sso/v2/configurations/public', '/frontegg/vendors/public', + '/frontegg/identity/resources/sso/custom/v1', + '/frontegg/identity/resources/configurations/v1/captcha-policy/public', + ]; + final fetches = urls.map((u) => "fetch('$u').catch(()=>null)").join(',\n'); + final refresh = includeRefresh + ? "fetch('/frontegg/identity/resources/auth/v1/user/token/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>null)," + : ''; + return "window.addEventListener('load',()=>{Promise.allSettled([$refresh$fetches]);});"; + } + + void _renderEmbeddedContinue(HttpResponse res, Map> query) { + final email = _fv(query, 'email', 'test@frontegg.com'); + final redirectUri = _fv(query, 'redirect_uri'); + final stateValue = _fv(query, 'state'); + + if (email.endsWith('@saml-domain.com') || email.endsWith('@oidc-domain.com')) { + final title = email.endsWith('@saml-domain.com') ? 'OKTA SAML Mock Server' : 'OKTA OIDC Mock Server'; + final body = ''' +

$title

+
+ + + + +
'''; + _sendHtml(res, 200, title, body); + return; + } + + final body = ''' +

Password Login

+
+ + + + + +
'''; + _sendHtml(res, 200, 'Password Login', body); + } + + void _completeEmbeddedPassword(HttpResponse res, List bodyBytes) { + final form = _parseUrlEncodedForm(bodyBytes); + final email = form['email'] ?? 'test@frontegg.com'; + final redirectUri = form['redirect_uri'] ?? ''; + final stateValue = form['state'] ?? ''; + final code = _state.issueCode(email: email, redirectUri: redirectUri, state: stateValue); + _sendRedirect(res, _buildCallbackUrl(redirectUri, code: code, state: stateValue)); + } + + void _completeBrowserFlow(HttpResponse res, Map> query) { + final email = _fv(query, 'email', 'browser@frontegg.com'); + final redirectUri = _fv(query, 'redirect_uri'); + final stateValue = _fv(query, 'state'); + final code = _state.issueCode(email: email, redirectUri: redirectUri, state: stateValue); + _sendRedirect(res, _buildCallbackUrl(redirectUri, code: code, state: stateValue)); + } + + void _handleOAuthToken(HttpResponse res, List bodyBytes) { + final body = _parseJsonBody(bodyBytes); + final grantType = body['grant_type'] as String? ?? ''; + + switch (grantType) { + case 'authorization_code': + final code = body['code'] as String? ?? ''; + if (code.isEmpty) return _sendJson(res, 400, {'error': 'missing_code'}); + final authCode = _state.consumeCode(code); + if (authCode == null) return _sendJson(res, 400, {'error': 'invalid_code'}); + final issued = _state.issueRefreshToken(authCode.email); + _sendJson(res, 200, _authResponse(issued.record, issued.token)); + + case 'refresh_token': + final refreshToken = body['refresh_token'] as String? ?? ''; + if (refreshToken.isEmpty) return _sendJson(res, 400, {'error': 'missing_refresh_token'}); + final session = _state.refreshSession(refreshToken); + if (session == null) return _sendJson(res, 401, {'error': 'invalid_refresh_token'}); + _sendJson(res, 200, _authResponse(session, refreshToken)); + + default: + _sendJson(res, 400, {'error': 'unsupported_grant_type $grantType'}); + } + } + + void _handleSilentAuthorize(HttpResponse res, Map headers) { + final refreshToken = _refreshTokenFromCookies(headers['cookie']); + if (refreshToken == null) return _sendJson(res, 401, {'error': 'invalid_refresh_cookie'}); + final session = _state.validRefreshTokenRecord(refreshToken); + if (session == null) return _sendJson(res, 401, {'error': 'invalid_refresh_cookie'}); + _sendJson(res, 200, _authResponse(session, refreshToken)); + } + + void _handleSocialLoginConfig(HttpResponse res) { + _sendJson(res, 200, [ + { + 'type': 'google', + 'active': true, + 'customised': false, + 'clientId': 'mock-google-client-id', + 'redirectUrl': '$urlRoot/oauth/account/social/success', + 'redirectUrlPattern': '$urlRoot/oauth/account/social/success', + 'options': {'verifyEmail': false}, + 'additionalScopes': [], + }, + ]); + } + + void _handleHostedRefresh(HttpResponse res, Map headers) { + final refreshToken = _refreshTokenFromCookies(headers['cookie']); + if (refreshToken == null) return _sendJson(res, 401, {'errors': ['Session not found']}); + final session = _state.refreshSession(refreshToken); + if (session == null) return _sendJson(res, 401, {'errors': ['Session not found']}); + _sendJson(res, 200, _authResponse(session, refreshToken)); + } + + void _handleHostedSSOPrelogin(HttpResponse res, List bodyBytes) { + final body = _parseJsonBody(bodyBytes); + final email = (body['email'] as String? ?? '').toLowerCase(); + if (email.endsWith('@saml-domain.com')) { + _sendJson(res, 200, {'type': 'saml', 'tenantId': _tenantId(email)}); + } else if (email.endsWith('@oidc-domain.com')) { + _sendJson(res, 200, {'type': 'oidc', 'tenantId': _tenantId(email)}); + } else { + _sendJson(res, 404, {'errors': ['SSO domain was not found']}); + } + } + + void _handleHostedPasswordLogin(HttpResponse res, List bodyBytes) { + final body = _parseJsonBody(bodyBytes); + final email = body['email'] as String? ?? 'test@frontegg.com'; + final issued = _state.issueRefreshToken(email); + final authData = jsonEncode(_authResponse(issued.record, issued.token)); + res.statusCode = 200; + res.headers.contentType = ContentType.json; + res.headers.add('set-cookie', 'fe_refresh_demo_embedded_e2e=${issued.token}; Path=/; HttpOnly; SameSite=Lax'); + res.write(authData); + res.close(); + } + + void _handleHostedPostlogin(HttpResponse res, List bodyBytes) { + final body = _parseJsonBody(bodyBytes); + final hostedState = body['state'] as String? ?? ''; + final context = _state.hostedLoginContext(hostedState); + if (context == null) return _sendJson(res, 400, {'error': 'invalid_state'}); + + String email; + final token = body['token'] as String?; + if (token != null) { + email = _emailFromBearerToken(token) ?? context.loginHint; + } else { + email = context.loginHint; + } + if (email.isEmpty) return _sendJson(res, 400, {'error': 'missing_token'}); + + final code = _state.issueCode(email: email, redirectUri: context.redirectUri, state: context.originalState); + final redirectUrl = _buildCallbackUrl(context.redirectUri, code: code, state: context.originalState); + _state.recordHostedPostloginCompletion(hostedState, email); + _sendJson(res, 200, {'redirectUrl': redirectUrl}); + } + + void _handleHostedPostloginRedirect(HttpResponse res, Map> query) { + final hostedState = _fv(query, 'state'); + final context = _state.hostedLoginContext(hostedState); + final email = _state.completedHostedLoginEmail(hostedState); + if (context == null || email == null) return _sendJson(res, 400, {'error': 'missing_postlogin_completion'}); + final code = _state.issueCode(email: email, redirectUri: context.redirectUri, state: context.originalState); + _sendRedirect(res, _buildCallbackUrl(context.redirectUri, code: code, state: context.originalState)); + } + + void _handleMockGoogleAuthorize(HttpResponse res, Map> query) { + final redirectUri = _fv(query, 'redirect_uri'); + final stateValue = _fv(query, 'state'); + if (redirectUri.isEmpty || stateValue.isEmpty) { + _sendHtml(res, 400, 'Invalid mock Google request', '

Invalid mock Google request

'); + return; + } + const email = 'google-social@frontegg.com'; + final code = _state.issueCode(email: email, redirectUri: redirectUri, state: stateValue); + final body = ''' +

Mock Google Login

+

Fake Google account: $email

+
+ + + +
'''; + _sendHtml(res, 200, 'Mock Google Login', body); + } + + void _handleSocialLoginSuccess(HttpResponse res, Map> query) { + final code = _fv(query, 'code'); + final rawState = _fv(query, 'state'); + if (_state.authCode(code) == null) return _sendJson(res, 400, {'error': 'invalid_social_code'}); + + final redirectUri = _fv(query, 'redirectUri'); + if (redirectUri.isEmpty) { + final socialState = _decodeSocialState(rawState); + final bundleId = socialState?['bundleId'] as String?; + if (bundleId == null || bundleId.isEmpty) return _sendJson(res, 400, {'error': 'invalid_social_state'}); + _sendRedirect(res, _buildGeneratedRedirectCodeCallbackUrl(bundleId, rawState, code)); + return; + } + + final pendingError = _state.consumeEmbeddedSocialSuccessOAuthError(); + if (pendingError != null) { + final callbackState = _state.latestHostedLoginState ?? rawState; + final bundleId = _embeddedBundleIdentifier(redirectUri); + if (bundleId != null) { + _sendRedirect(res, _buildGeneratedRedirectErrorCallbackUrl(bundleId, callbackState, pendingError.$1, pendingError.$2)); + } else { + _sendRedirect(res, _buildCallbackUrl(redirectUri, state: callbackState, error: pendingError.$1, errorDescription: pendingError.$2)); + } + return; + } + + _sendRedirect(res, _buildCallbackUrl(redirectUri, code: code, state: rawState)); + } + + void _handleMe(HttpResponse res, Map headers) { + final email = _emailFromAuthHeader(headers['authorization']); + if (email == null) return _sendJson(res, 401, {'error': 'missing_access_token'}); + _sendJson(res, 200, _userResponse(email)); + } + + void _handleTenants(HttpResponse res, Map headers) { + final email = _emailFromAuthHeader(headers['authorization']); + if (email == null) return _sendJson(res, 401, {'error': 'missing_access_token'}); + final tenant = _tenantResponse(email); + _sendJson(res, 200, {'tenants': [tenant], 'activeTenant': tenant}); + } + + void _handleLogout(HttpResponse res, Map headers) { + final rt = _refreshTokenFromCookies(headers['cookie']); + if (rt != null) _state.invalidateRefreshToken(rt); + _sendJson(res, 200, {'ok': true}); + } + + // --------------------------------------------------------------------------- + // Response helpers + // --------------------------------------------------------------------------- + + void _sendJson(HttpResponse res, int status, Object payload) { + res.statusCode = status; + res.headers.contentType = ContentType.json; + res.write(jsonEncode(payload)); + res.close(); + } + + void _sendText(HttpResponse res, int status, String body) { + res.statusCode = status; + res.headers.contentType = ContentType.text; + res.write(body); + res.close(); + } + + void _sendHtml(HttpResponse res, int status, String title, String body) { + final page = ''' + +${_he(title)} + +$body'''; + res.statusCode = status; + res.headers.contentType = ContentType.html; + res.write(page); + res.close(); + } + + void _sendRedirect(HttpResponse res, String location) { + stderr.writeln('[MockServer] 302 → $location'); + res.statusCode = 302; + res.headers.set('location', location); + res.close(); + } + + Future _sendQueuedResponse(HttpResponse res, Map spec, String method) async { + final delayMs = spec['delay_ms'] as int? ?? 0; + final closeConnection = spec['close_connection'] as bool? ?? false; + + if (delayMs > 0) { + await Future.delayed(Duration(milliseconds: delayMs)); + } + + if (closeConnection) { + res.close(); + return; + } + + var status = spec['status'] as int? ?? 200; + final redirect = spec['redirect'] as String?; + if (redirect != null && redirect.isNotEmpty) { + res.headers.set('location', redirect); + if (!spec.containsKey('status')) status = 302; + } + + final json = spec['json']; + final bodyStr = spec['body'] as String?; + if (json != null) { + res.statusCode = status; + res.headers.contentType = ContentType.json; + res.write(jsonEncode(json)); + } else if (bodyStr != null) { + res.statusCode = status; + res.headers.contentType = ContentType.text; + res.write(bodyStr); + } else { + res.statusCode = status; + } + res.close(); + } + + // --------------------------------------------------------------------------- + // Token / auth helpers + // --------------------------------------------------------------------------- + + String _accessToken({required String email, required int tokenVersion, required int expiresIn}) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final payload = { + 'sub': 'user-${email.split('@').first}', + 'email': email, + 'name': _userName(email), + 'tenantId': _tenantId(email), + 'tenantIds': [_tenantId(email)], + 'profilePictureUrl': 'https://example.com/avatar.png', + 'exp': now + expiresIn, + 'iat': now, + 'token_version': tokenVersion, + }; + final header = _encodeBase64UrlJson({'alg': 'none', 'typ': 'JWT'}); + final body = _encodeBase64UrlJson(payload); + return '$header.$body.signature'; + } + + Map _authResponse(_RefreshTokenRecord session, String refreshToken) { + final policy = _state.tokenPolicy(session.email); + final at = _accessToken(email: session.email, tokenVersion: session.tokenVersion, expiresIn: policy.accessTTL); + return { + 'token_type': 'Bearer', + 'refresh_token': refreshToken, + 'access_token': at, + 'id_token': at, + 'expires_in': policy.accessTTL, + 'expires': (DateTime.now().millisecondsSinceEpoch ~/ 1000 + policy.accessTTL).toString(), + }; + } + + Map _tenantResponse(String email) { + final tid = _tenantId(email); + const now = '2026-03-26T00:00:00.000Z'; + return { + 'id': tid, 'name': '${_userName(email)} Tenant', 'tenantId': tid, + 'createdAt': now, 'updatedAt': now, 'isReseller': false, 'metadata': '{}', 'vendorId': 'vendor-demo', + }; + } + + Map _userResponse(String email) { + final tenant = _tenantResponse(email); + return { + 'id': 'user-${email.split('@').first}', 'email': email, 'mfaEnrolled': false, + 'name': _userName(email), 'profilePictureUrl': 'https://example.com/avatar.png', + 'phoneNumber': null, 'profileImage': null, 'roles': [], 'permissions': [], + 'tenantId': tenant['id'], 'tenantIds': [tenant['id']], 'tenants': [tenant], + 'activeTenant': tenant, 'activatedForTenant': true, 'metadata': '{}', 'verified': true, 'superUser': false, + }; + } + + // --------------------------------------------------------------------------- + // Utility + // --------------------------------------------------------------------------- + + String _userName(String email) { + final local = email.split('@').first; + return local.replaceAll('-', ' ').replaceAll('.', ' ') + .split(' ').where((w) => w.isNotEmpty) + .map((w) => '${w[0].toUpperCase()}${w.substring(1)}').join(' '); + } + + String _tenantId(String email) { + final local = email.split('@').first.replaceAll('.', '-').replaceAll('_', '-'); + return 'tenant-$local'; + } + + String _fv(Map> query, String key, [String defaultValue = '']) => + query[key]?.firstOrNull ?? defaultValue; + + String _normalizePath(String path) { + if (path.isEmpty) return '/'; + return path.startsWith('/') ? path : '/$path'; + } + + String _he(String s) => s + .replaceAll('&', '&').replaceAll('"', '"') + .replaceAll("'", ''').replaceAll('<', '<').replaceAll('>', '>'); + + String _jsLiteral(String s) { + final json = jsonEncode([s]); + return json.substring(1, json.length - 1); + } + + String _encodeBase64UrlJson(Map value) { + final data = utf8.encode(jsonEncode(value)); + return base64Url.encode(data).replaceAll('=', ''); + } + + Map? _decodeBase64UrlJson(String value) { + if (value.isEmpty) return null; + try { + var normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + final remainder = normalized.length % 4; + if (remainder > 0) normalized += '=' * (4 - remainder); + final decoded = utf8.decode(base64.decode(normalized)); + return jsonDecode(decoded) as Map?; + } catch (_) { + return null; + } + } + + Map? _decodeSocialState(String rawState) { + try { + return jsonDecode(rawState) as Map?; + } catch (_) { + return null; + } + } + + String? _emailFromBearerToken(String token) { + final parts = token.split('.'); + if (parts.length < 2) return null; + final payload = _decodeBase64UrlJson(parts[1]); + return payload?['email'] as String?; + } + + String? _emailFromAuthHeader(String? header) { + if (header == null || !header.startsWith('Bearer ')) return null; + return _emailFromBearerToken(header.substring(7).trim()); + } + + String? _refreshTokenFromCookies(String? cookieHeader) { + if (cookieHeader == null) return null; + for (final segment in cookieHeader.split(';')) { + final chunk = segment.trim(); + if (chunk.startsWith('fe_refresh_')) { + final eq = chunk.indexOf('='); + if (eq > 0) return chunk.substring(eq + 1); + } + } + return null; + } + + Map _parseUrlEncodedForm(List bodyBytes) { + final bodyStr = utf8.decode(bodyBytes); + final values = {}; + for (final pair in bodyStr.split('&')) { + if (pair.isEmpty) continue; + final parts = pair.split('='); + final name = Uri.decodeComponent(parts[0].replaceAll('+', ' ')); + final value = parts.length > 1 ? Uri.decodeComponent(parts[1].replaceAll('+', ' ')) : ''; + values[name] = value; + } + return values; + } + + Map _parseJsonBody(List bodyBytes) { + if (bodyBytes.isEmpty) return {}; + try { + return jsonDecode(utf8.decode(bodyBytes)) as Map; + } catch (_) { + return {}; + } + } + + String _buildCallbackUrl(String redirectUri, {String? code, String? state, String? error, String? errorDescription}) { + final uri = Uri.parse(redirectUri); + final params = Map.from(uri.queryParameters); + if (code != null) params['code'] = code; + if (state != null && state.isNotEmpty) params['state'] = state; + if (error != null) params['error'] = error; + if (errorDescription != null) params['error_description'] = errorDescription; + return uri.replace(queryParameters: params).toString(); + } + + String? _embeddedBundleIdentifier(String redirectUri) { + final uri = Uri.tryParse(redirectUri); + if (uri == null) return null; + const prefix = '/oauth/account/redirect/ios/'; + if (!uri.path.startsWith(prefix)) return null; + final suffix = uri.path.substring(prefix.length); + final bundleId = suffix.split('/').first; + return bundleId.isEmpty ? null : bundleId; + } + + String _buildGeneratedRedirectCodeCallbackUrl(String bundleId, String state, String code) { + return '${bundleId.toLowerCase()}://127.0.0.1/ios/oauth/callback?state=${Uri.encodeComponent(state)}&code=${Uri.encodeComponent(code)}&social-login-callback=true'; + } + + String _buildGeneratedRedirectErrorCallbackUrl(String bundleId, String state, String error, String errorDescription) { + return '${bundleId.toLowerCase()}://127.0.0.1/ios/oauth/callback?error=${Uri.encodeComponent(error)}&error_description=${Uri.encodeComponent(errorDescription)}&state=${Uri.encodeComponent(state)}'; + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +class _LoggedRequest { + final String method; + final String path; + _LoggedRequest({required this.method, required this.path}); +} + +class _AuthCode { + final String email; + final String redirectUri; + final String state; + _AuthCode({required this.email, required this.redirectUri, required this.state}); +} + +class _HostedLoginContext { + final String redirectUri; + final String originalState; + final String loginHint; + _HostedLoginContext({required this.redirectUri, required this.originalState, required this.loginHint}); +} + +class _TokenPolicy { + final int accessTTL; + final int refreshTTL; + final int startingTokenVersion; + _TokenPolicy({this.accessTTL = 3600, this.refreshTTL = 86400, this.startingTokenVersion = 1}); + static final defaultPolicy = _TokenPolicy(); +} + +class _RefreshTokenRecord { + final String email; + final double expiresAt; + int tokenVersion; + _RefreshTokenRecord({required this.email, required this.expiresAt, required this.tokenVersion}); +} + +class _IssuedRefreshToken { + final String token; + final _RefreshTokenRecord record; + _IssuedRefreshToken({required this.token, required this.record}); +} + +class _MockAuthState { + final _queuedResponses = >>{}; + final _authCodes = {}; + final _hostedLoginContexts = {}; + String? latestHostedLoginState; + final _completedHostedLogins = {}; + final _refreshTokens = {}; + final _tokenPolicies = {}; + (String, String)? _pendingEmbeddedSocialOAuthError; + int _codeCounter = 0; + + _MockAuthState() { reset(); } + + void reset() { + _queuedResponses.clear(); + _authCodes.clear(); + _hostedLoginContexts.clear(); + latestHostedLoginState = null; + _completedHostedLogins.clear(); + _tokenPolicies.clear(); + _pendingEmbeddedSocialOAuthError = null; + _refreshTokens.clear(); + _refreshTokens['signup-refresh-token'] = _RefreshTokenRecord( + email: 'signup@frontegg.com', + expiresAt: DateTime.now().millisecondsSinceEpoch / 1000 + _TokenPolicy.defaultPolicy.refreshTTL, + tokenVersion: _TokenPolicy.defaultPolicy.startingTokenVersion, + ); + } + + void enqueue({required String method, required String path, required List> responses}) { + final key = '${method.toUpperCase()} $path'; + _queuedResponses.putIfAbsent(key, () => []).addAll(responses); + } + + Map? dequeue({required String method, required String path}) { + final key = '${method.toUpperCase()} $path'; + final q = _queuedResponses[key]; + if (q == null || q.isEmpty) return null; + final r = q.removeAt(0); + if (q.isEmpty) _queuedResponses.remove(key); + return r; + } + + String issueCode({required String email, required String redirectUri, required String state}) { + _codeCounter++; + final code = 'code-$_codeCounter-${DateTime.now().millisecondsSinceEpoch}'; + _authCodes[code] = _AuthCode(email: email, redirectUri: redirectUri, state: state); + return code; + } + + String issueHostedLoginContext({required String redirectUri, required String originalState, required String loginHint}) { + final hostedState = 'hosted-${DateTime.now().millisecondsSinceEpoch}-${_codeCounter++}'; + _hostedLoginContexts[hostedState] = _HostedLoginContext(redirectUri: redirectUri, originalState: originalState, loginHint: loginHint); + latestHostedLoginState = hostedState; + return hostedState; + } + + _HostedLoginContext? hostedLoginContext(String hostedState) => _hostedLoginContexts[hostedState]; + + void recordHostedPostloginCompletion(String hostedState, String email) { + _completedHostedLogins[hostedState] = email; + } + + String? completedHostedLoginEmail(String hostedState) => _completedHostedLogins[hostedState]; + + _AuthCode? consumeCode(String code) { + final ac = _authCodes[code]; + _authCodes.remove(code); + return ac; + } + + _AuthCode? authCode(String code) => _authCodes[code]; + + void configureTokenPolicy({required String email, required int accessTTL, required int refreshTTL, int startingTokenVersion = 1}) { + _tokenPolicies[email.toLowerCase()] = _TokenPolicy(accessTTL: accessTTL, refreshTTL: refreshTTL, startingTokenVersion: startingTokenVersion); + } + + _TokenPolicy tokenPolicy(String email) => _tokenPolicies[email.toLowerCase()] ?? _TokenPolicy.defaultPolicy; + + _IssuedRefreshToken issueRefreshToken(String email) { + final token = 'refresh-${DateTime.now().millisecondsSinceEpoch}-${_codeCounter++}'; + final policy = tokenPolicy(email); + final record = _RefreshTokenRecord( + email: email, + expiresAt: DateTime.now().millisecondsSinceEpoch / 1000 + policy.refreshTTL, + tokenVersion: policy.startingTokenVersion, + ); + _refreshTokens[token] = record; + return _IssuedRefreshToken(token: token, record: record); + } + + _RefreshTokenRecord? validRefreshTokenRecord(String refreshToken) { + final record = _refreshTokens[refreshToken]; + if (record == null) return null; + if (record.expiresAt <= DateTime.now().millisecondsSinceEpoch / 1000) { + _refreshTokens.remove(refreshToken); + return null; + } + return record; + } + + _RefreshTokenRecord? refreshSession(String refreshToken) { + final record = validRefreshTokenRecord(refreshToken); + if (record == null) return null; + record.tokenVersion++; + return record; + } + + void invalidateRefreshToken(String refreshToken) { + _refreshTokens.remove(refreshToken); + } + + void queueEmbeddedSocialSuccessOAuthError(String errorCode, String errorDescription) { + _pendingEmbeddedSocialOAuthError = (errorCode, errorDescription); + } + + (String, String)? consumeEmbeddedSocialSuccessOAuthError() { + final e = _pendingEmbeddedSocialOAuthError; + _pendingEmbeddedSocialOAuthError = null; + return e; + } +} diff --git a/embedded/ios/Flutter/Profile.xcconfig b/embedded/ios/Flutter/Profile.xcconfig new file mode 100644 index 00000000..73272fc1 --- /dev/null +++ b/embedded/ios/Flutter/Profile.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" +#include "Generated.xcconfig" diff --git a/embedded/ios/Flutter/RunnerUITests.debug.xcconfig b/embedded/ios/Flutter/RunnerUITests.debug.xcconfig new file mode 100644 index 00000000..e359c824 --- /dev/null +++ b/embedded/ios/Flutter/RunnerUITests.debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/embedded/ios/Flutter/RunnerUITests.profile.xcconfig b/embedded/ios/Flutter/RunnerUITests.profile.xcconfig new file mode 100644 index 00000000..97a40dc9 --- /dev/null +++ b/embedded/ios/Flutter/RunnerUITests.profile.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.profile.xcconfig" +#include "Generated.xcconfig" diff --git a/embedded/ios/Flutter/RunnerUITests.release.xcconfig b/embedded/ios/Flutter/RunnerUITests.release.xcconfig new file mode 100644 index 00000000..926c3645 --- /dev/null +++ b/embedded/ios/Flutter/RunnerUITests.release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.release.xcconfig" +#include "Generated.xcconfig" diff --git a/embedded/ios/Podfile b/embedded/ios/Podfile index 1a227fed..c44106a7 100644 --- a/embedded/ios/Podfile +++ b/embedded/ios/Podfile @@ -31,7 +31,6 @@ target 'Runner' do use_frameworks! use_modular_headers! - # FronteggSwift comes from frontegg_flutter (SPM or podspec), do not add here to avoid duplicate Sentry.framework flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerUITests' do diff --git a/embedded/ios/Podfile.lock b/embedded/ios/Podfile.lock index 687390d7..04ce69d8 100644 --- a/embedded/ios/Podfile.lock +++ b/embedded/ios/Podfile.lock @@ -28,9 +28,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 - patrol: 51b76cc7c11a2933ee3e72482d930c75b9d4ec73 + fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f + patrol: cf2cd48c7f3e5171610111994f7b466cd76d1f57 -PODFILE CHECKSUM: 14580e531132820758bcd53bd55e220c6816422a +PODFILE CHECKSUM: c9be09f6a61958d6788db8b9ee489767b65cfa92 COCOAPODS: 1.16.2 diff --git a/embedded/ios/Runner.xcodeproj/project.pbxproj b/embedded/ios/Runner.xcodeproj/project.pbxproj index f479919b..3c6a5735 100644 --- a/embedded/ios/Runner.xcodeproj/project.pbxproj +++ b/embedded/ios/Runner.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + F749021D4217E4FDB00AE95F /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = ""; }; 8720A573D3AE80B939DC8F78 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -75,6 +76,9 @@ D8FA32ECBAB49AF3072AEA31 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; DAB89492F93B6A5C7EE033F5 /* Pods_Runner_RunnerUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner_RunnerUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E70A2787799D5E18794660CE /* Pods-Runner-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.release.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.release.xcconfig"; sourceTree = ""; }; + F749021D4217E4FDB00AE95C /* RunnerUITests.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = RunnerUITests.debug.xcconfig; path = Flutter/RunnerUITests.debug.xcconfig; sourceTree = ""; }; + F749021D4217E4FDB00AE95D /* RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = RunnerUITests.release.xcconfig; path = Flutter/RunnerUITests.release.xcconfig; sourceTree = ""; }; + F749021D4217E4FDB00AE95E /* RunnerUITests.profile.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = RunnerUITests.profile.xcconfig; path = Flutter/RunnerUITests.profile.xcconfig; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ @@ -130,7 +134,11 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + F749021D4217E4FDB00AE95F /* Profile.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, + F749021D4217E4FDB00AE95C /* RunnerUITests.debug.xcconfig */, + F749021D4217E4FDB00AE95D /* RunnerUITests.release.xcconfig */, + F749021D4217E4FDB00AE95E /* RunnerUITests.profile.xcconfig */, ); name = Flutter; sourceTree = ""; @@ -549,7 +557,7 @@ }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + baseConfigurationReference = F749021D4217E4FDB00AE95F /* Profile.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -557,7 +565,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 3R2X6557U2; + DEVELOPMENT_TEAM = AM6NK96AX6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Frontegg Demo"; @@ -582,17 +590,16 @@ }; 4577226E2D0719C00058D3CA /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + baseConfigurationReference = F749021D4217E4FDB00AE95C /* RunnerUITests.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3R2X6557U2; + DEVELOPMENT_TEAM = AM6NK96AX6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -615,17 +622,16 @@ }; 4577226F2D0719C00058D3CA /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + baseConfigurationReference = F749021D4217E4FDB00AE95D /* RunnerUITests.release.xcconfig */; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3R2X6557U2; + DEVELOPMENT_TEAM = AM6NK96AX6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -647,17 +653,16 @@ }; 457722702D0719C00058D3CA /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + baseConfigurationReference = F749021D4217E4FDB00AE95E /* RunnerUITests.profile.xcconfig */; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3R2X6557U2; + DEVELOPMENT_TEAM = AM6NK96AX6; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -798,7 +803,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 3R2X6557U2; + DEVELOPMENT_TEAM = AM6NK96AX6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Frontegg Demo"; @@ -832,7 +837,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 3R2X6557U2; + DEVELOPMENT_TEAM = AM6NK96AX6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Frontegg Demo"; diff --git a/embedded/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/embedded/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 0bad6571..00000000 --- a/embedded/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pins" : [ - { - "identity" : "frontegg-ios-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/frontegg/frontegg-ios-swift.git", - "state" : { - "revision" : "94245149133a10adc3c228ef18343c8b5371606a", - "version" : "1.2.79" - } - }, - { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa.git", - "state" : { - "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", - "version" : "8.58.0" - } - } - ], - "version" : 2 -} diff --git a/embedded/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/embedded/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0bad6571..10306bc4 100644 --- a/embedded/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/embedded/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/frontegg/frontegg-ios-swift.git", "state" : { - "revision" : "94245149133a10adc3c228ef18343c8b5371606a", - "version" : "1.2.79" + "revision" : "f6ffe223cd3cafd80104d27e65c71d24c00a6e86" } }, { diff --git a/embedded/ios/Runner/AppDelegate.swift b/embedded/ios/Runner/AppDelegate.swift index d9f6094e..f76b8fd3 100644 --- a/embedded/ios/Runner/AppDelegate.swift +++ b/embedded/ios/Runner/AppDelegate.swift @@ -10,10 +10,30 @@ import FronteggSwift _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { +#if DEBUG + // Tell FronteggSwift this is an E2E test build so it allows the embedded + // WebView to navigate to the localhost mock server (FronteggRuntime.isTesting). + setenv("frontegg-testing", "true", 1) + let viaC = String(cString: getenv("frontegg-testing") ?? "") + let viaProcessInfo = ProcessInfo.processInfo.environment["frontegg-testing"] ?? "" + NSLog("[E2E] env via getenv=%@, via ProcessInfo=%@", viaC, viaProcessInfo) +#endif + GeneratedPluginRegistrant.register(with: self) DefaultLoader.customLoaderView = AnyView(Text("Loading...")) - + + // Register the E2E method channel. Use the FlutterPluginRegistry API + // so it works even when window.rootViewController is not yet available + // (UIScene lifecycle / Xcode 16.4). + let registrar = self.registrar(forPlugin: "FronteggE2E")! + let e2eChannel = FlutterMethodChannel( + name: "frontegg_e2e", + binaryMessenger: registrar.messenger() + ) + e2eChannel.setMethodCallHandler(handleE2EMethodCall) + NSLog("[E2E] e2e channel registered via FlutterPluginRegistry") + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -52,6 +72,65 @@ import FronteggSwift return false } + private func handleE2EMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initializeForE2E": + guard let args = call.arguments as? [String: Any], + let baseUrl = args["baseUrl"] as? String, + let clientId = args["clientId"] as? String else { + result(FlutterError(code: "MISSING_PARAM", message: "baseUrl and clientId required", details: nil)) + return + } + let resetState = args["resetState"] as? Bool ?? true + let forceNetworkPathOffline = args["forceNetworkPathOffline"] as? Bool ?? false + NSLog("[E2E] initializeForE2E: baseUrl=%@, resetState=%d", baseUrl, resetState ? 1 : 0) + Task { @MainActor in +#if DEBUG + NSLog("[E2E] DEBUG block active") + if resetState { + await FronteggApp.shared.resetForTesting(baseUrlOverride: baseUrl) + NSLog("[E2E] resetForTesting done") + } + FronteggApp.shared.configureTestingNetworkPathAvailability( + forceNetworkPathOffline ? false : nil + ) + if let offline = args["enableOfflineMode"] as? Bool { + FronteggApp.shared.configureTestingOfflineMode(offline) + } else if let offline = args["enableOfflineMode"] as? NSNumber { + FronteggApp.shared.configureTestingOfflineMode(offline.boolValue) + } +#else + NSLog("[E2E] DEBUG block NOT active") +#endif + FronteggApp.shared.shouldPromptSocialLoginConsent = false + NSLog("[E2E] pre-manualInit embeddedMode=%d, baseUrl=%@", FronteggApp.shared.auth.embeddedMode ? 1 : 0, FronteggApp.shared.baseUrl) + FronteggApp.shared.manualInit( + baseUrl: baseUrl, + cliendId: clientId, + handleLoginWithSocialLogin: true, + handleLoginWithSSO: true, + handleLoginWithCustomSSO: true, + handleLoginWithCustomSocialLoginProvider: true, + handleLoginWithSocialProvider: true, + entitlementsEnabled: false + ) + NSLog("[E2E] manualInit done, baseUrl=%@, embeddedMode=%d", FronteggApp.shared.baseUrl, FronteggApp.shared.auth.embeddedMode ? 1 : 0) + result(nil) + } + + case "resetForTesting": + FronteggApp.shared.auth.logout { _ in + result(nil) + } + + case "writeBootstrap", "consumeBootstrap": + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + func showToast(message: String) { guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return } diff --git a/embedded/ios/Runner/Info.plist b/embedded/ios/Runner/Info.plist index 1833b991..6fe10d6a 100644 --- a/embedded/ios/Runner/Info.plist +++ b/embedded/ios/Runner/Info.plist @@ -45,6 +45,24 @@ LaunchScreen UIMainStoryboardFile Main + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + 127.0.0.1 + + NSExceptionAllowsInsecureHTTPLoads + + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/embedded/lib/e2e_test_mode.dart b/embedded/lib/e2e_test_mode.dart new file mode 100644 index 00000000..a1b788b3 --- /dev/null +++ b/embedded/lib/e2e_test_mode.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class E2ETestMode { + static const _channel = MethodChannel('frontegg_e2e'); + static const bootstrapFileName = 'e2e_embedded_bootstrap.json'; + + static bool _isEnabled = false; + static String? _baseUrl; + static String? _clientId; + static bool _forceNetworkPathOffline = false; + static bool? _enableOfflineMode; + + static bool get isEnabled => _isEnabled; + static String? get baseUrl => _baseUrl; + static String? get clientId => _clientId; + static bool get forceNetworkPathOffline => _forceNetworkPathOffline; + static bool? get enableOfflineMode => _enableOfflineMode; + + static Future applyBootstrapIfPresent() async { + try { + final result = await _channel.invokeMethod('consumeBootstrap'); + if (result != null) { + _isEnabled = true; + _baseUrl = result['baseUrl'] as String?; + _clientId = result['clientId'] as String?; + _forceNetworkPathOffline = + result['forceNetworkPathOffline'] as bool? ?? false; + _enableOfflineMode = result['enableOfflineMode'] as bool?; + debugPrint( + 'E2ETestMode: enabled, baseUrl=$_baseUrl, clientId=$_clientId', + ); + } + } on MissingPluginException { + debugPrint('E2ETestMode: no native handler (expected outside E2E)'); + } catch (e) { + debugPrint('E2ETestMode: bootstrap failed: $e'); + } + } + + static Future writeBootstrap({ + required String baseUrl, + required String clientId, + bool resetState = true, + bool forceNetworkPathOffline = false, + bool? enableOfflineMode, + }) async { + try { + await _channel.invokeMethod('writeBootstrap', { + 'baseUrl': baseUrl.replaceAll(RegExp(r'/+$'), ''), + 'clientId': clientId, + 'resetState': resetState, + 'forceNetworkPathOffline': forceNetworkPathOffline, + if (enableOfflineMode != null) 'enableOfflineMode': enableOfflineMode, + }); + } on MissingPluginException { + debugPrint('E2ETestMode: writeBootstrap not available on this platform'); + } + } + + static Future initializeForE2E({ + required String baseUrl, + required String clientId, + bool resetState = true, + bool forceNetworkPathOffline = false, + bool? enableOfflineMode, + }) async { + try { + await _channel.invokeMethod('initializeForE2E', { + 'baseUrl': baseUrl.replaceAll(RegExp(r'/+$'), ''), + 'clientId': clientId, + 'resetState': resetState, + 'forceNetworkPathOffline': forceNetworkPathOffline, + if (enableOfflineMode != null) 'enableOfflineMode': enableOfflineMode, + }); + _isEnabled = true; + _baseUrl = baseUrl; + _clientId = clientId; + _forceNetworkPathOffline = forceNetworkPathOffline; + _enableOfflineMode = enableOfflineMode; + } on MissingPluginException { + debugPrint('E2ETestMode: initializeForE2E not available'); + } + } + + static Future resetForTesting() async { + try { + await _channel.invokeMethod('resetForTesting'); + } on MissingPluginException { + debugPrint('E2ETestMode: resetForTesting not available'); + } + } +} diff --git a/embedded/lib/login_page.dart b/embedded/lib/login_page.dart index 2311e759..83d39ef1 100644 --- a/embedded/lib/login_page.dart +++ b/embedded/lib/login_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:frontegg_flutter/frontegg_flutter.dart'; +import 'e2e_test_mode.dart'; import 'widgets/footer.dart'; import 'widgets/frontegg_app_bar.dart'; @@ -94,8 +95,6 @@ class Body extends StatelessWidget { style: textTheme.bodyMedium, ), const SizedBox(height: 16), - // Login with email and password - // loginHint is the email of the user (optional) ElevatedButton( key: const ValueKey('LoginButton'), child: const Text('Sign in'), @@ -112,43 +111,36 @@ class Body extends StatelessWidget { }, ), const SizedBox(height: 8), - // Login with Google Provider ElevatedButton( child: const Text('Login with Google'), onPressed: () async { await frontegg.socialLogin( provider: FronteggSocialProvider.google, ); - debugPrint('Login via Google Finished'); }, ), const SizedBox(height: 8), - // Login with Apple Provider ElevatedButton( child: const Text('Login with Apple'), onPressed: () async { await frontegg.socialLogin( provider: FronteggSocialProvider.apple, ); - debugPrint('Login via Apple Finished'); }, ), const SizedBox(height: 8), - // Custom social login by providing the id ElevatedButton( child: const Text('Custom social login'), onPressed: () async { await frontegg.customSocialLogin( id: '6fbe9b2d-bfce-4804-aa4b-a1503db588ae', ); - debugPrint('Custom Social Login Finished'); }, ), const SizedBox(height: 8), - // Request Authorized With Tokens ElevatedButton( child: const Text('Request Authorized With Tokens'), onPressed: () async { @@ -157,14 +149,12 @@ class Body extends StatelessWidget { deviceTokenCookie: 'ef5b2160-5b84-4ad9-afc2-e9beafacc778', ); - debugPrint( 'Request Authorized With Tokens Finished, Result = $user', ); }, ), const SizedBox(height: 8), - // Login with Passkeys ElevatedButton( child: const Text('Login with Passkeys'), onPressed: () async { @@ -176,6 +166,7 @@ class Body extends StatelessWidget { } }, ), + if (E2ETestMode.isEnabled) ..._buildE2EButtons(frontegg), ], ), ), @@ -186,4 +177,132 @@ class Body extends StatelessWidget { ), ); } + + List _buildE2EButtons(FronteggFlutter frontegg) { + return [ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + const Text( + 'E2E Test Controls', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2EEmbeddedPasswordButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.login(loginHint: 'test@frontegg.com'); + } catch (e) { + debugPrint('E2E password login failed: $e'); + } + }, + child: const Text('E2E Password Login'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2EEmbeddedSAMLButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.login(loginHint: 'test@saml-domain.com'); + } catch (e) { + debugPrint('E2E SAML login failed: $e'); + } + }, + child: const Text('E2E SAML Login'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2EEmbeddedOIDCButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.login(loginHint: 'test@oidc-domain.com'); + } catch (e) { + debugPrint('E2E OIDC login failed: $e'); + } + }, + child: const Text('E2E OIDC Login'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2ESeedRequestAuthorizeTokenButton', + child: ElevatedButton( + onPressed: () async { + debugPrint('E2E: seeded request-authorize refresh token'); + }, + child: const Text('E2E Seed Request Authorize'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'RequestAuthorizeButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.requestAuthorize( + refreshToken: 'signup-refresh-token', + ); + } catch (e) { + debugPrint('E2E request authorize failed: $e'); + } + }, + child: const Text('E2E Request Authorize'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2ECustomSSOButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.customSocialLogin(id: 'e2e-custom-sso'); + } catch (e) { + debugPrint('E2E custom SSO failed: $e'); + } + }, + child: const Text('E2E Custom SSO'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2EDirectSocialLoginButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.directLogin( + url: 'mock-social-provider', + ephemeralSession: false, + ); + } catch (e) { + debugPrint('E2E direct social failed: $e'); + } + }, + child: const Text('E2E Direct Social Login'), + ), + ), + const SizedBox(height: 8), + Semantics( + label: 'E2EEmbeddedGoogleSocialButton', + child: ElevatedButton( + onPressed: () async { + try { + await frontegg.socialLogin( + provider: FronteggSocialProvider.google, + ephemeralSession: false, + ); + } catch (e) { + debugPrint('E2E Google social failed: $e'); + } + }, + child: const Text('E2E Google Social Login'), + ), + ), + ]; + } } diff --git a/embedded/lib/main_page.dart b/embedded/lib/main_page.dart index a9ca26db..f5bbfc41 100644 --- a/embedded/lib/main_page.dart +++ b/embedded/lib/main_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:frontegg_flutter/frontegg_flutter.dart'; +import 'e2e_test_mode.dart'; import 'login_page.dart'; +import 'no_connection_page.dart'; import 'user_page.dart'; /// Main page @@ -11,30 +13,93 @@ class MainPage extends StatelessWidget { @override Widget build(BuildContext context) { final frontegg = context.frontegg; + final textTheme = Theme.of(context).textTheme; return Scaffold( - body: Center( - // StreamBuilder to listen to the state of the authentication - child: StreamBuilder( - stream: frontegg.stateChanged, - builder: - (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasData) { - final state = snapshot.data!; - if (state.isAuthenticated && state.user != null) { - // If the user is authenticated and the user is not null, show the user page - return const UserPage(); - } else if (state.initializing) { - // If the app is initializing, show a loading indicator - return const CircularProgressIndicator(); - } else { - // If the user is not authenticated, show the login page - return const LoginPage(); - } + body: StreamBuilder( + stream: frontegg.stateChanged, + builder: (BuildContext context, AsyncSnapshot snapshot) { + late final Widget main; + if (snapshot.hasData) { + final state = snapshot.data!; + // Match Swift `MyApp`: global loader while `isLoading` — do not show + // NoConnection or Login until loading finishes, or cold start can briefly + // show NoConnection (isOfflineMode) without LoginPageRoot and break E2E. + if (state.initializing || state.isLoading) { + main = const Center(child: CircularProgressIndicator()); + } else if (state.isAuthenticated && state.user != null) { + main = Semantics( + label: 'UserPageRoot', + child: const UserPage(), + ); + } else if (state.isAuthenticated && state.user == null) { + main = Semantics( + label: 'AuthenticatedOfflineRoot', + child: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Authenticated (offline)', + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'User details will load when connectivity is restored.', + style: textTheme.bodyLarge?.copyWith( + color: const Color(0xFF7A7C81), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } else if (state.isOfflineMode) { + main = const NoConnectionPage(); + } else { + main = Semantics( + label: 'LoginPageRoot', + child: const LoginPage(), + ); } + } else { + main = const Center(child: SizedBox()); + } - return const SizedBox(); - }, - ), + return Stack( + fit: StackFit.expand, + children: [ + Center(child: main), + if (E2ETestMode.isEnabled && + E2ETestMode.forceNetworkPathOffline && + snapshot.hasData) + Positioned( + left: 0, + top: 0, + child: IgnorePointer( + child: Opacity( + opacity: 0, + child: Semantics( + label: snapshot.data!.isAuthenticated + ? 'AuthenticatedOfflineModeEnabled' + : 'UnauthenticatedOfflineModeEnabled', + child: const Text( + '0', + style: TextStyle(fontSize: 1, height: 0.01), + ), + ), + ), + ), + ), + ], + ); + }, ), ); } diff --git a/embedded/lib/no_connection_page.dart b/embedded/lib/no_connection_page.dart new file mode 100644 index 00000000..4e67648c --- /dev/null +++ b/embedded/lib/no_connection_page.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:frontegg_flutter/frontegg_flutter.dart'; + +import 'e2e_test_mode.dart'; +import 'widgets/frontegg_app_bar.dart'; + +/// Shown when the native SDK reports offline / no-connection for unauthenticated users. +/// Parity with Swift `NoConnectionPage` (identifiers: NoConnectionPageRoot, RetryConnectionButton). +class NoConnectionPage extends StatelessWidget { + const NoConnectionPage({super.key}); + + @override + Widget build(BuildContext context) { + final frontegg = context.frontegg; + final theme = Theme.of(context); + + return Semantics( + container: true, + label: 'NoConnectionPageRoot', + child: Scaffold( + appBar: const FronteggAppBar(), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wifi_off, + size: 88, + color: theme.colorScheme.error, + ), + const SizedBox(height: 20), + Text( + 'No Connection', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + "It looks like you're offline.\nPlease check your internet connection and try again.", + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 28), + Semantics( + label: 'RetryConnectionButton', + button: true, + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + frontegg.refreshToken(); + }, + child: const Text('Retry'), + ), + ), + ), + if (E2ETestMode.isEnabled) ...[ + const SizedBox(height: 16), + Opacity( + opacity: 0, + child: Semantics( + label: 'NoConnectionPageSeenEver', + child: const Text('1'), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/embedded/lib/user_page.dart b/embedded/lib/user_page.dart index a35cd53c..d684168b 100644 --- a/embedded/lib/user_page.dart +++ b/embedded/lib/user_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:frontegg_flutter/frontegg_flutter.dart'; +import 'e2e_test_mode.dart'; import 'theme.dart'; import 'utils.dart'; import 'widgets/footer.dart'; @@ -65,6 +66,27 @@ class _UserPageState extends State { const SizedBox(height: 40), if (_messageWidget != null) _messageWidget!, const SizedBox(height: 16), + if (E2ETestMode.isEnabled && + E2ETestMode.forceNetworkPathOffline) + Padding( + padding: const EdgeInsets.only( + left: 24, + right: 24, + bottom: 8, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Semantics( + label: 'OfflineModeBadge', + child: Text( + 'Offline Mode', + style: textTheme.titleSmall?.copyWith( + color: const Color(0xFF888888), + ), + ), + ), + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Card( @@ -131,7 +153,6 @@ class _UserPageState extends State { right: 10.5, bottom: 8, ), - // Sensitive action button child: ElevatedButton( onPressed: () async { const maxAge = Duration(minutes: 1); @@ -164,7 +185,7 @@ class _UserPageState extends State { top: 8.0, left: 10.5, right: 10.5, - bottom: 24, + bottom: 8, ), child: ElevatedButton( onPressed: () async { @@ -205,6 +226,29 @@ class _UserPageState extends State { child: const Text("Load Entitlements"), ), ), + Padding( + padding: const EdgeInsets.only( + top: 8.0, + left: 10.5, + right: 10.5, + bottom: 8, + ), + child: Semantics( + label: 'LogoutButton', + child: ElevatedButton( + key: const ValueKey('LogoutButton'), + onPressed: () async { + await frontegg.logout(); + }, + child: const Text("Logout"), + ), + ), + ), + if (E2ETestMode.isEnabled && + state.accessToken != null) + _buildAccessTokenVersionLabel( + state.accessToken!, + ), ], ), ), @@ -244,6 +288,32 @@ class _UserPageState extends State { ); } + Widget _buildAccessTokenVersionLabel(String accessToken) { + int? version; + try { + final parts = accessToken.split('.'); + if (parts.length > 1) { + var payload = parts[1]; + payload = payload.replaceAll('-', '+').replaceAll('_', '/'); + final remainder = payload.length % 4; + if (remainder > 0) payload += '=' * (4 - remainder); + final decoded = utf8.decode(base64.decode(payload)); + final json = jsonDecode(decoded) as Map; + version = json['token_version'] as int?; + } + } catch (_) {} + return Padding( + padding: const EdgeInsets.only(left: 10.5, right: 10.5, bottom: 24), + child: Semantics( + label: 'AccessTokenVersionValue', + child: Text( + '${version ?? 0}', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ); + } + void _showSuccessMessage(String msg) { final size = MediaQuery.sizeOf(context); setState(() { diff --git a/embedded/pubspec.lock b/embedded/pubspec.lock index 5f8f3a75..7e795cd2 100644 --- a/embedded/pubspec.lock +++ b/embedded/pubspec.lock @@ -139,7 +139,7 @@ packages: path: ".." relative: true source: path - version: "1.0.40" + version: "1.0.41" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -311,13 +311,13 @@ packages: source: hosted version: "2.7.2" patrol_log: - dependency: transitive + dependency: "direct overridden" description: name: patrol_log - sha256: "98b2701400c7a00b11533ab942bdeb44c3c714746e7cdb12e6cb93b6d06361da" + sha256: a2360db165c34692665c0de146e5157887d6b584fdccca8f141f947a5acf1b2e url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.8.0" platform: dependency: transitive description: diff --git a/embedded/pubspec.yaml b/embedded/pubspec.yaml index 90468cb1..9eafeefd 100644 --- a/embedded/pubspec.yaml +++ b/embedded/pubspec.yaml @@ -21,9 +21,12 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 - patrol: ^3.13.1 + patrol: ^3.14.0 uuid: ^4.5.1 +dependency_overrides: + patrol_log: ^0.8.0 + flutter: uses-material-design: true diff --git a/ios/frontegg_flutter.podspec b/ios/frontegg_flutter.podspec index b6b47c82..04492365 100644 --- a/ios/frontegg_flutter.podspec +++ b/ios/frontegg_flutter.podspec @@ -15,8 +15,7 @@ A new Flutter plugin project. s.source = { :path => '.' } s.source_files = 'frontegg_flutter/Sources/frontegg_flutter/**/*.swift' s.dependency 'Flutter' - # FronteggSwift is integrated via Swift Package Manager (SPM) through `Package.swift`. - # CocoaPods dependency is intentionally omitted to avoid version mismatch issues. + # FronteggSwift is integrated via Swift Package Manager (SPM) in the host app's Xcode project. s.platform = :ios, '14.0' # Flutter.framework does not contain a i386 slice. diff --git a/ios/frontegg_flutter/Package.swift b/ios/frontegg_flutter/Package.swift index 12ba9a82..89417896 100644 --- a/ios/frontegg_flutter/Package.swift +++ b/ios/frontegg_flutter/Package.swift @@ -13,7 +13,11 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/frontegg/frontegg-ios-swift.git", exact: "1.2.79"), + // Pin to a master commit that includes FronteggRuntime.isTesting support, + // which is required for the embedded WebView to allow navigation to + // localhost mock servers in E2E tests. Released versions (≤1.2.79) do + // not have this and unconditionally block 127.0.0.1 navigation. + .package(url: "https://github.com/frontegg/frontegg-ios-swift.git", revision: "f6ffe223cd3cafd80104d27e65c71d24c00a6e86"), ], targets: [ .target( diff --git a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggState.swift b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggState.swift index bccf97bc..01127632 100644 --- a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggState.swift +++ b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggState.swift @@ -10,6 +10,7 @@ struct FronteggState { var showLoader: NSNumber var appLink: NSNumber var refreshingToken: NSNumber + var isOfflineMode: NSNumber public func toMap() -> Dictionary { return [ @@ -22,6 +23,7 @@ struct FronteggState { "showLoader": showLoader, "appLink": appLink, "refreshingToken": refreshingToken, + "isOfflineMode": isOfflineMode, ] } } diff --git a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift index 1b2d8f97..4544f070 100644 --- a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift +++ b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift @@ -20,12 +20,13 @@ class FronteggStateListenerImpl: FronteggStateListener { func subscribe() { let auth = fronteggApp.auth var stateChange: AnyPublisher { - return Publishers.Merge5 ( + return Publishers.MergeMany( auth.$refreshingToken.map { _ in }, auth.$isAuthenticated.map {_ in }, auth.$isLoading.map {_ in }, auth.$initializing.map {_ in }, - auth.$showLoader.map {_ in } + auth.$showLoader.map {_ in }, + auth.$isOfflineMode.map { _ in }, ) .eraseToAnyPublisher() } @@ -70,7 +71,8 @@ class FronteggStateListenerImpl: FronteggStateListener { initializing: NSNumber(value: auth.initializing), showLoader: NSNumber(value: auth.showLoader), appLink: NSNumber(value: auth.appLink), - refreshingToken: NSNumber(value: auth.refreshingToken) + refreshingToken: NSNumber(value: auth.refreshingToken), + isOfflineMode: NSNumber(value: auth.isOfflineMode) ) self.sendState(state: state) diff --git a/lib/src/models/frontegg_state.dart b/lib/src/models/frontegg_state.dart index 38f4ac10..2aebf1c0 100644 --- a/lib/src/models/frontegg_state.dart +++ b/lib/src/models/frontegg_state.dart @@ -1,5 +1,11 @@ import 'package:frontegg_flutter/frontegg_flutter.dart'; +bool _readBool(Object? value) { + if (value is bool) return value; + if (value is num) return value != 0; + return false; +} + /// Represents the authentication state in the Frontegg system. class FronteggState { /// The access token for authenticated requests, or `null` if not authenticated. @@ -29,6 +35,9 @@ class FronteggState { /// Whether the token is currently being refreshed. final bool refreshingToken; + /// Whether the SDK considers the app in offline / no-connection UX (native-driven). + final bool isOfflineMode; + /// Creates a [FronteggState] instance with the given parameters. const FronteggState({ this.accessToken, @@ -40,6 +49,7 @@ class FronteggState { this.showLoader = true, this.appLink = false, this.refreshingToken = false, + this.isOfflineMode = false, }); @override @@ -55,7 +65,8 @@ class FronteggState { initializing == other.initializing && appLink == other.appLink && showLoader == other.showLoader && - refreshingToken == other.refreshingToken; + refreshingToken == other.refreshingToken && + isOfflineMode == other.isOfflineMode; @override int get hashCode => @@ -67,7 +78,8 @@ class FronteggState { initializing.hashCode ^ appLink.hashCode ^ showLoader.hashCode ^ - refreshingToken.hashCode; + refreshingToken.hashCode ^ + isOfflineMode.hashCode; Map toMap() { return { @@ -80,6 +92,7 @@ class FronteggState { "appLink": appLink, "showLoader": showLoader, "refreshingToken": refreshingToken, + "isOfflineMode": isOfflineMode, }; } @@ -96,11 +109,12 @@ class FronteggState { showLoader: map["showLoader"] as bool, appLink: map["appLink"] as bool, refreshingToken: map["refreshingToken"] as bool, + isOfflineMode: _readBool(map["isOfflineMode"]), ); } @override String toString() { - return 'FronteggState{accessToken: $accessToken, refreshToken: $refreshToken, user: $user, isAuthenticated: $isAuthenticated, isLoading: $isLoading, initializing: $initializing, showLoader: $showLoader, appLink: $appLink, refreshingToken: $refreshingToken}'; + return 'FronteggState{accessToken: $accessToken, refreshToken: $refreshToken, user: $user, isAuthenticated: $isAuthenticated, isLoading: $isLoading, initializing: $initializing, showLoader: $showLoader, appLink: $appLink, refreshingToken: $refreshingToken, isOfflineMode: $isOfflineMode}'; } } diff --git a/test/fixtures/test_maps.dart b/test/fixtures/test_maps.dart index 9396bfc2..4c526da7 100644 --- a/test/fixtures/test_maps.dart +++ b/test/fixtures/test_maps.dart @@ -125,6 +125,7 @@ const tFronteggStateMap = { "showLoader": false, "appLink": true, "refreshingToken": false, + "isOfflineMode": false, }; const tLoadingFronteggStateMap = { @@ -137,6 +138,7 @@ const tLoadingFronteggStateMap = { "showLoader": true, "appLink": false, "refreshingToken": true, + "isOfflineMode": false, }; const tLoadedFronteggStateMap = { @@ -149,4 +151,5 @@ const tLoadedFronteggStateMap = { "showLoader": true, "appLink": false, "refreshingToken": false, + "isOfflineMode": false, }; diff --git a/test/fixtures/test_models.dart b/test/fixtures/test_models.dart index ee5748a7..d487796b 100644 --- a/test/fixtures/test_models.dart +++ b/test/fixtures/test_models.dart @@ -124,6 +124,7 @@ const tFronteggState = FronteggState( showLoader: false, appLink: true, refreshingToken: false, + isOfflineMode: false, ); const tLoadingFronteggState = FronteggState( @@ -136,6 +137,7 @@ const tLoadingFronteggState = FronteggState( showLoader: true, appLink: false, refreshingToken: true, + isOfflineMode: false, ); final tLoadedFronteggState = FronteggState( @@ -148,4 +150,5 @@ final tLoadedFronteggState = FronteggState( showLoader: true, appLink: false, refreshingToken: false, + isOfflineMode: false, );