diff --git a/.github/actions/run-e2e-suite/action.yml b/.github/actions/run-e2e-suite/action.yml new file mode 100644 index 0000000..69cbbe5 --- /dev/null +++ b/.github/actions/run-e2e-suite/action.yml @@ -0,0 +1,62 @@ +# Runs the Quickstart E2E suite: a ThunderID server, the provisioned test application and user, +# the sample built onto a simulator, and the Maestro flows driving it. +# +# Shared by the PR builder and the nightly workflow so the two cannot drift. All of the actual +# work lives in Tests/e2e/run-e2e.sh, which is also what a contributor runs locally, so a green +# run here and a green run on a laptop mean the same thing. + +name: Run E2E Suite +description: Build the Quickstart sample and drive it with Maestro against a real ThunderID server + +inputs: + thunderid-version: + description: ThunderID release to test against, without the leading "v". Defaults to the latest release. + required: false + default: "" + artifact-suffix: + description: Appended to the debug artifact name, so concurrent callers do not collide. + required: false + default: "" + +runs: + using: composite + steps: + - name: 🗄️ Cache Swift Packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: Samples/Quickstart/.build + key: ${{ runner.os }}-swift-sample-${{ hashFiles('Samples/Quickstart/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-swift-sample- + + - name: 🧭 Install Maestro + shell: bash + # Pinned rather than latest: an unpinned install swaps the test runner out from under the + # suite between runs, so a CI failure cannot be reproduced against the version a + # contributor has locally. Bump this deliberately, after checking the flows against it. + env: + MAESTRO_VERSION: "2.9.0" + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: 🔬 Run E2E Suite + shell: bash + working-directory: Tests/e2e + env: + THUNDERID_VERSION: ${{ inputs.thunderid-version }} + run: ./run-e2e.sh + + - name: 📤 Upload Debug Artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: e2e-debug-ios${{ inputs.artifact-suffix }} + # Maestro writes screenshots, the recorded hierarchy and its own logs here on failure, + # which is the only way to tell a genuine regression from a flake after the fact. + path: | + ~/.maestro/tests + Tests/e2e/report.xml + Tests/e2e/.thunderid-server/server.log + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..38a9541 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,43 @@ +# Runs the Quickstart E2E suite against the latest published ThunderID release, every night. +# +# The PR builder runs the same suite through the same composite action. The nightly exists +# because these flows talk to a freshly downloaded server release, so a scheduled run catches +# breakage introduced by a new server release rather than by a change in this repository. +# +# Uses: +# OS: macos-latest + +name: 🌙 Nightly E2E + +on: + schedule: + # 02:30 UTC. + - cron: "30 2 * * *" + workflow_dispatch: + inputs: + thunderid-version: + description: ThunderID release to test against, without the leading "v". Blank uses the latest. + required: false + default: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PRODUCT_NAME: "ThunderID" + +jobs: + e2e: + name: 🎭 E2E Tests + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: 📥 Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔬 Run E2E Suite + uses: ./.github/actions/run-e2e-suite + with: + thunderid-version: ${{ inputs.thunderid-version }} + artifact-suffix: -nightly diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index e9b48c0..a3f29ed 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -115,3 +115,21 @@ jobs: - name: 🔨 Build Quickstart Sample working-directory: Samples/Quickstart run: swift build + + e2e: + name: 🎭 E2E Tests + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: 📥 Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔬 Run E2E Suite + uses: ./.github/actions/run-e2e-suite + with: + # Fork PRs run without repository secrets, so the unauthenticated GitHub API call that + # resolves "latest" hits its rate limit easily. The nightly workflow already exists to + # catch breakage from new server releases, so pin the PR builder to a known-good + # version instead of resolving it live; bump this alongside the server's own releases. + thunderid-version: "1.0.1" diff --git a/.gitignore b/.gitignore index 0a971fa..211f45b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,9 @@ fastlane/test_output # OS generated files .DS_Store + +# ThunderID server distribution downloaded by the E2E suite +.thunderid-server/ + +# Maestro JUnit report from the E2E suite. +Tests/e2e/report.xml diff --git a/Samples/Quickstart/README.md b/Samples/Quickstart/README.md index 3cabc5e..92b27d3 100644 --- a/Samples/Quickstart/README.md +++ b/Samples/Quickstart/README.md @@ -5,78 +5,13 @@ ThunderID iOS Quickstart demonstrates the full authentication lifecycle using th **Flow demonstrated:** 1. App opens → unauthenticated state (sign-in screen) 2. User initiates sign-in / sign-up → SDK starts app-native Flow Execution -3. User completes the flow and logs in to ThunderID -4. Successful → authenticated state with profile information, token debugging, and sign-out button. +3. User completes the flow +4. Sign-in → authenticated state with profile information, token debugging, and sign-out button. + Sign-up creates the account but does not start a session, so it returns to the landing screen + and the new credentials have to be used to sign in. 5. User taps Sign Out → session terminated, returns to sign-in screen -## Prerequisites +## Setup & Run -- Xcode 15+ -- A running ThunderID instance - -## Setup - -```bash -cp Config.plist.example Sources/Config.plist -# Edit Sources/Config.plist with your ThunderID base URL and application ID -``` - -### Configuration - -> [!NOTE] -> This sample uses app-native authentication (Flow Execution API), so only the base URL and application ID are required — no OAuth2 client ID or redirect URIs. - -| Variable | Description | -|----------|-------------| -| `THUNDERID_BASE_URL` | Base URL of your ThunderID server (HTTPS) | -| `THUNDERID_APP_ID` | Application UUID from ThunderID console | - - -💡 `Sources/Config.plist` is gitignored. Never commit real credentials. - -### Attestation via Apple App Attest (optional) - -If the application enforces platform attestation, set `THUNDERID_ATTESTATION_ENABLED` to `true` in -`Sources/Config.plist`, then rebuild. When enabled, the sample mints a token via -`AppAttestTokenProvider` (Apple App Attest) and sends it with every native flow-initiate request. - -Testing this end-to-end requires: -- A **physical device** — App Attest is unavailable in the simulator. -- A signing team and the **App Attest capability** enabled on the target, so Xcode adds the - `com.apple.developer.devicecheck.appattest-environment` entitlement. -- The **Team ID** and **Bundle ID** registered on the ThunderID application's attestation settings to - match the ones the app is signed with. ThunderID derives the expected App ID from - `.` and rejects a token whose attested App ID differs. - -The `Attestation-Token` header carries the base64-encoded App Attest attestation object exactly as -`DCAppAttestService.attestKey` returns it — do not wrap it in a JSON envelope. The challenge should -come from the server in production; this sample generates it locally to exercise the SDK hook. - -### Passkeys (WebAuthn) - -Passkey registration/authentication via `ASAuthorizationPlatformPublicKeyCredentialProvider` -requires the app's relying party ID to be backed by a real **Associated Domain**, not `localhost`. -Without this, `ASAuthorizationController` fails immediately with -`Error Domain=com.apple.AuthenticationServices.AuthorizationError Code=1004`. - -This sample ships `Sources/Quickstart.entitlements` with a placeholder -`webcredentials:your-thunderid-domain.example` entry. To exercise passkeys end-to-end: - -1. Replace the placeholder domain in `Sources/Quickstart.entitlements` with the domain your - ThunderID server is actually reachable at (it must serve valid HTTPS — self-signed certs and - `localhost` will not work). -2. Host an `apple-app-site-association` file at - `https:///.well-known/apple-app-site-association` declaring this app's Team ID and - `PRODUCT_BUNDLE_IDENTIFIER` (`dev.thunderid.Quickstart`) under `webcredentials.apps`. -3. Make sure the server's passkey `rp.id` matches that same domain — the SDK - (`PasskeyAuthSession`) passes whatever `rp.id` the server returns straight through to - `ASAuthorizationPlatformPublicKeyCredentialProvider`. -4. Set a `DEVELOPMENT_TEAM` and enable the **Associated Domains** capability for the target in - Xcode (Signing & Capabilities) so the entitlement is actually applied to the build. - -Exposing a local ThunderID instance under a real, HTTPS-reachable domain (e.g. via a tunnel) is -left to you — this sample only wires up the entitlement/documentation, not the tunnel itself. - -## Run - -Open in Xcode via `Package.swift` and run on an iOS 16+ simulator or device. +See [Try the iOS Sample App](https://thunderid.dev/docs/v1.0.x/sdks/ios/guides/try-the-sample-app) +in the iOS SDK docs for prerequisites, configuration, attestation, passkeys, and run instructions. diff --git a/Samples/Quickstart/thunderid-config/thunderid-config.yaml b/Samples/Quickstart/thunderid-config/thunderid-config.yaml new file mode 100644 index 0000000..94fc982 --- /dev/null +++ b/Samples/Quickstart/thunderid-config/thunderid-config.yaml @@ -0,0 +1,34 @@ +--- +resource_type: application +# Application used exclusively by the iOS Quickstart E2E suite. It has no OAuth profile: the +# mobile SDKs authenticate app-natively via the Flow Execution API, so the sample app needs only +# the base URL and this application ID. +# +# The ID is fixed so the sample's Config.plist and the Maestro flows can be committed against a +# known value instead of discovering it at run time. +id: 019e5b10-1001-7a2b-9c3d-4e5f60718293 +ouHandle: default +name: Mobile Quickstart E2E App +description: Application for the ThunderID mobile SDK quickstart E2E suites +type: mobile +# A mobile application must normally prove its binary identity via platform attestation before it +# may initiate a flow directly, otherwise /flow/execute fails with FES-1016. Apple App Attest is +# unavailable in the iOS Simulator and Play Integrity requires real Play Services, so neither can +# be satisfied on the emulated devices this suite drives. devMode skips that check. +# +# Test-only. Never enable this on a real tenant: it removes the guarantee that the client +# initiating a flow is the genuine, unmodified app. +# +# NOTE: POST /import currently drops this block silently (it reports success, then stores +# attestation: null), so run-e2e.sh re-applies it over PUT /applications/{id}. Once the +# import path preserves it, that extra step can go. +attestation: + devMode: true +authFlowHandle: default-flow +# The suite registers a fresh user through the UI rather than relying on a seeded account, so +# self-service registration has to be enabled for this application. +registrationFlowHandle: default-flow +isRegistrationFlowEnabled: true +signOutFlowHandle: default-flow +allowedUserTypes: + - Person diff --git a/Sources/ThunderID/http/LocalhostPinnedURLSession.swift b/Sources/ThunderID/http/LocalhostPinnedURLSession.swift index 58d7165..14d06c1 100644 --- a/Sources/ThunderID/http/LocalhostPinnedURLSession.swift +++ b/Sources/ThunderID/http/LocalhostPinnedURLSession.swift @@ -38,17 +38,36 @@ final class LocalhostPinnedURLSession: NSObject, URLSessionDelegate, URLSessionT return } + // Anything other than loopback goes through the system's own evaluation. Accepting a + // trust object without evaluating it *replaces* that evaluation rather than adding to + // it, so doing so for an arbitrary host would silently accept a forged certificate on + // the channel carrying credentials, assertions and refresh tokens. + guard Self.isLoopbackHost(host) else { + completionHandler(.performDefaultHandling, nil) + return + } + + // A pinned certificate is the strongest answer when one is bundled: match it exactly. if let pinned = pinnedCertificateData { guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0), (SecCertificateCopyData(certificate) as Data) == pinned else { completionHandler(.cancelAuthenticationChallenge, nil) return } + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + return } + // No pin, but the host is loopback, which no network attacker can occupy. This is what + // lets a development build reach a local server through its self-signed certificate. completionHandler(.useCredential, URLCredential(trust: serverTrust)) } + /// Whether `host` is a loopback address, and therefore unreachable by a network attacker. + static func isLoopbackHost(_ host: String) -> Bool { + ["localhost", "127.0.0.1", "::1", "[::1]"].contains(host) + } + func urlSession( _ session: URLSession, task: URLSessionTask, diff --git a/Tests/e2e/README.md b/Tests/e2e/README.md new file mode 100644 index 0000000..33612c8 --- /dev/null +++ b/Tests/e2e/README.md @@ -0,0 +1,60 @@ +# Quickstart E2E + +Maestro flows that drive the Quickstart sample through real authentication against a real +ThunderID server on an iOS Simulator. The flows live in [`flows/`](./flows); the +scripts alongside them get a server into the right state to run against. + +## Running locally + +```bash +# Everything: start a server, provision it, build and install the sample, run the flows +./run-e2e.sh + +# Iterate faster once the server is up and the sample is installed +./run-e2e.sh --skip-server --skip-build + +# Run a single flow +./run-e2e.sh flows/signin.yaml +``` + +Every stage is idempotent. A server that is already serving on `:8090` is reused rather than +restarted, and provisioning re-applies cleanly over an existing application and user. + +## What gets provisioned + +| Resource | Value | +|---|---| +| Application | `019e5b10-1001-7a2b-9c3d-4e5f60718293` (`Mobile Quickstart E2E App`) | +| Test user | `e2e_mobile_user` / `TestPassword@123` | + +The application is declared in +[`Samples/Quickstart/thunderid-config/thunderid-config.yaml`](../../Samples/Quickstart/thunderid-config/thunderid-config.yaml), +alongside the sample it configures. The sign-up flow +registers an additional throwaway user per run, named `e2e_signup_`. + +## Things worth knowing + +**The application must be `type: mobile` with `attestation.devMode: true`.** A mobile application +normally has to prove its binary identity through platform attestation before it can initiate a +flow directly, and `/flow/execute` rejects it with `FES-1016` otherwise. Apple App Attest does not +exist in the Simulator, so the check can never be satisfied on the device these flows run on. +`devMode` is test-only and must never be enabled on a real tenant. + +**`POST /import` silently drops the `attestation` block.** It reports the import as successful and +then stores `attestation: null`, which is why `run-e2e.sh` re-applies it over +`PUT /applications/{id}`. Once the import path preserves it, that step can be removed. + +**Sign-up does not sign the user in.** The registration flow completes without issuing an +assertion, so the app returns to the landing screen with the account created but no session. The +sign-up flow therefore signs in afterwards with the credentials it just registered, which is also +what proves the new account actually works. + +**Tokens survive `clearState`.** They are stored in the Keychain, which lives outside the app +container and outlives both a state reset and a reinstall. A previous run can leave the app +signed in, so every flow starts with the `ensure-signed-out` subflow rather than assuming a clean +device. + +**`npx thunderid` cannot be used in CI.** It renders an interactive TUI and aborts with +`bubbletea: could not open TTY` whenever stdout is not a terminal. `run-e2e.sh` downloads the +release directly and calls the distribution's own `setup.sh` and `start.sh`, which take the same +arguments non-interactively. diff --git a/Tests/e2e/flows/config.yaml b/Tests/e2e/flows/config.yaml new file mode 100644 index 0000000..7a7e12c --- /dev/null +++ b/Tests/e2e/flows/config.yaml @@ -0,0 +1,6 @@ +# Maestro workspace configuration for the iOS Quickstart E2E suite. +# +# Only the top-level flows are test entry points. Without this, `maestro test .maestro/` would +# also recurse into subflows/ and run the shared building blocks as if they were tests. +flows: + - "*.yaml" diff --git a/Tests/e2e/flows/signin.yaml b/Tests/e2e/flows/signin.yaml new file mode 100644 index 0000000..bad231d --- /dev/null +++ b/Tests/e2e/flows/signin.yaml @@ -0,0 +1,62 @@ +appId: dev.thunderid.Quickstart +name: Sign in and sign out +tags: + - auth +env: + E2E_USERNAME: e2e_mobile_user + E2E_PASSWORD: TestPassword@123 +--- +- launchApp: + clearState: true +- runFlow: subflows/ensure-signed-out.yaml + +- tapOn: "Sign in" + +# The credentials step is rendered from the flow definition the server returns, not from static +# native views. The SDK tags each input with the field's identifier and each button with the +# action's ref, so these ids track the server's flow response. +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 20000 + +# A slow CI runner can take long enough filling and submitting this form that the server's +# credentials-step challenge expires before submission, which silently resets the form instead +# of authenticating - "Session active" then never appears. That is an expired challenge, not a +# regression, so retry the fill-and-submit rather than fail outright. +- retry: + maxRetries: 2 + commands: + - tapOn: + id: "thunderid-field-username" + - inputText: ${E2E_USERNAME} + + # tapOn's own element-lookup timeout is much shorter than extendedWaitUntil's, and is not + # always enough on a loaded CI runner, so wait explicitly rather than tap blind. + - extendedWaitUntil: + visible: + id: "thunderid-field-password" + timeout: 20000 + - tapOn: + id: "thunderid-field-password" + - inputText: ${E2E_PASSWORD} + + - tapOn: + id: "thunderid-action-action_001" + + - runFlow: subflows/dismiss-save-password.yaml + + # Authenticated state. + - extendedWaitUntil: + visible: "Session active" + timeout: 30000 + +# ...and back out again, so the flow leaves the app as it found it. +- scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "Get started" + timeout: 20000 diff --git a/Tests/e2e/flows/signup.yaml b/Tests/e2e/flows/signup.yaml new file mode 100644 index 0000000..d8fa306 --- /dev/null +++ b/Tests/e2e/flows/signup.yaml @@ -0,0 +1,111 @@ +appId: dev.thunderid.Quickstart +name: Sign up a new user, then sign in as them +tags: + - auth +env: + E2E_PASSWORD: TestPassword@123 +--- +- launchApp: + clearState: true +- runFlow: subflows/ensure-signed-out.yaml + +# Registration has to claim a username that does not exist yet, so each run generates its own. +# This leaves a user behind on the target server per run, which is fine for a disposable CI +# instance; against a long-lived dev server, expect the accounts to accumulate. +- evalScript: ${output.username = 'e2e_signup_' + Date.now()} + +- tapOn: "Get started" + +# The registration step is server-rendered like the sign-in step, but its flow definition uses +# different action refs, so the submit button id differs from signin.yaml. +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 20000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${output.username} + +# tapOn's own element-lookup timeout is much shorter than extendedWaitUntil's, and is not always +# enough on a loaded CI runner, so wait explicitly rather than tap blind. +- extendedWaitUntil: + visible: + id: "thunderid-field-password" + timeout: 20000 +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_credentials" + +- runFlow: subflows/dismiss-save-password.yaml + +# Registration is multi-step: the credentials step is followed by a step collecting the +# attributes the user schema requires (email here). Each step is a fresh server-rendered view +# with its own action ref. CI runners render this step noticeably slower than a local Mac, so +# this uses the same 30s budget as the other post-submit waits below rather than 20s. +- extendedWaitUntil: + visible: + id: "thunderid-field-email" + timeout: 30000 + +- tapOn: + id: "thunderid-field-email" +- inputText: ${output.username}@example.com + +- tapOn: + id: "thunderid-action-action_schema_attrs" + +# Registration completes without issuing an assertion, so the user is created but NOT signed in +# and the app falls back to the landing screen. Signing in with the credentials just registered +# is what proves the account is actually usable. +- extendedWaitUntil: + visible: "Get started" + timeout: 30000 + +- tapOn: "Sign in" +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 20000 + +# A slow CI runner can take long enough filling and submitting this form that the server's +# credentials-step challenge expires before submission, which silently resets the form instead +# of authenticating - "Session active" then never appears. That is an expired challenge, not a +# regression, so retry the fill-and-submit rather than fail outright. +- retry: + maxRetries: 2 + commands: + - tapOn: + id: "thunderid-field-username" + - inputText: ${output.username} + + # tapOn's own element-lookup timeout is much shorter than extendedWaitUntil's, and is not + # always enough on a loaded CI runner, so wait explicitly rather than tap blind. + - extendedWaitUntil: + visible: + id: "thunderid-field-password" + timeout: 20000 + - tapOn: + id: "thunderid-field-password" + - inputText: ${E2E_PASSWORD} + + - tapOn: + id: "thunderid-action-action_001" + + - runFlow: subflows/dismiss-save-password.yaml + + - extendedWaitUntil: + visible: "Session active" + timeout: 30000 + +- scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "Get started" + timeout: 20000 diff --git a/Tests/e2e/flows/subflows/dismiss-save-password.yaml b/Tests/e2e/flows/subflows/dismiss-save-password.yaml new file mode 100644 index 0000000..de1d2fe --- /dev/null +++ b/Tests/e2e/flows/subflows/dismiss-save-password.yaml @@ -0,0 +1,12 @@ +appId: dev.thunderid.Quickstart +--- +# iOS offers to store submitted credentials in the Keychain ("Save Password?") after a password +# field is submitted. It is a system alert drawn over the app, so it swallows the taps and +# assertions that follow a sign-in or sign-up. It is also not guaranteed to appear - whether iOS +# prompts depends on prior AutoFill state - so it has to be dismissed conditionally rather than +# unconditionally waited for. +- runFlow: + when: + visible: "Save Password?" + commands: + - tapOn: "Not Now" diff --git a/Tests/e2e/flows/subflows/ensure-signed-out.yaml b/Tests/e2e/flows/subflows/ensure-signed-out.yaml new file mode 100644 index 0000000..8546690 --- /dev/null +++ b/Tests/e2e/flows/subflows/ensure-signed-out.yaml @@ -0,0 +1,26 @@ +appId: dev.thunderid.Quickstart +--- +# Bring the app to the unauthenticated landing screen, whatever state it starts in. +# +# The SDK persists tokens in the Keychain, which is outside the app container and therefore +# survives both `clearState: true` and an app reinstall. A previous flow can consequently leave +# the app authenticated, so an explicit sign-out is the only reliable way to get back to a known +# starting point on a device that is not freshly created. +- runFlow: + when: + visible: "Session active" + commands: + - scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 + - tapOn: "Sign out" + - extendedWaitUntil: + visible: "Get started" + timeout: 20000 + +# A bare assertVisible relies on Maestro's short default lookup timeout, which is not always +# enough for the landing screen to render on a loaded CI simulator - wait explicitly. +- extendedWaitUntil: + visible: "Sign in" + timeout: 20000 diff --git a/Tests/e2e/run-e2e.sh b/Tests/e2e/run-e2e.sh new file mode 100755 index 0000000..73a5915 --- /dev/null +++ b/Tests/e2e/run-e2e.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# +# Run the iOS Quickstart E2E suite end to end: start a ThunderID server, provision the test +# application and user, build and install the sample, then drive it with Maestro. +# +# Every stage is idempotent, so re-running is safe and is the normal way to iterate. +# +# Usage: +# ./run-e2e.sh Everything +# ./run-e2e.sh --skip-server Server already running and provisioned +# ./run-e2e.sh --skip-build Sample already installed on the device +# ./run-e2e.sh flows/signin.yaml Run one flow instead of the whole suite +# +# Any argument that is not a recognised flag is passed through to Maestro, so extra Maestro +# options and a specific flow path both work. +# +# Environment: +# THUNDERID_VERSION Release to run, without the leading "v" (default: latest release) +# SERVER_URL Where the server is reachable (default https://localhost:8090) +# ADMIN_USERNAME Admin user to bootstrap (default admin) +# ADMIN_PASSWORD Admin password (default admin) +# E2E_USERNAME Test user to create (default e2e_mobile_user) +# E2E_PASSWORD Test user password (default TestPassword@123) +# INSTALL_DIR Where to unpack the distribution (default ./.thunderid-server) +# +# Why not `npx thunderid`? That wrapper renders an interactive TUI and aborts with +# "bubbletea: could not open TTY" when stdout is not a terminal, which is always the case on a CI +# runner. The distribution's own setup.sh/start.sh take the same arguments non-interactively. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVER_URL="${SERVER_URL:-https://localhost:8090}" +ADMIN_USER="${ADMIN_USERNAME:-admin}" +ADMIN_PASS="${ADMIN_PASSWORD:-admin}" +E2E_USER="${E2E_USERNAME:-e2e_mobile_user}" +E2E_PASS="${E2E_PASSWORD:-TestPassword@123}" +INSTALL_DIR="${INSTALL_DIR:-$SCRIPT_DIR/.thunderid-server}" + +# The sample owns its own application config, not the test script. +CONFIG_FILE="$SCRIPT_DIR/../../Samples/Quickstart/thunderid-config/thunderid-config.yaml" + +# Must match the `id` in $CONFIG_FILE and the application ID the sample is built with. +APP_ID="019e5b10-1001-7a2b-9c3d-4e5f60718293" + +do_server=true +do_build=true +do_test=true +maestro_args=() +for arg in "$@"; do + case "$arg" in + --skip-server) do_server=false ;; + --skip-build) do_build=false ;; + --skip-test) do_test=false ;; + -h | --help) sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) maestro_args+=("$arg") ;; + esac +done + +for tool in curl jq openssl; do + command -v "$tool" >/dev/null 2>&1 || { echo "ERROR: $tool is required but not installed." >&2; exit 1; } +done + +# --------------------------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------------------------- +start_server() { + # Reuse a server that is already serving. All three mobile SDK suites bind the same port, so + # failing here would mean tearing down a perfectly good server just to start an identical one. + # Provisioning runs regardless and is idempotent, so the reused server still ends up correct. + if curl -sk -o /dev/null --max-time 3 "$SERVER_URL/health/liveness" 2>/dev/null; then + echo "==> A server is already serving at $SERVER_URL, reusing it" + return 0 + fi + + command -v unzip >/dev/null 2>&1 || { echo "ERROR: unzip is required but not installed." >&2; exit 1; } + + local pkg_os pkg_arch + case "$(uname -s)" in + Darwin) pkg_os="macos" ;; + Linux) pkg_os="linux" ;; + *) echo "ERROR: unsupported OS $(uname -s)." >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) pkg_arch="arm64" ;; + x86_64 | amd64) pkg_arch="x64" ;; + *) echo "ERROR: unsupported architecture $(uname -m)." >&2; exit 1 ;; + esac + + local version="${THUNDERID_VERSION:-}" + if [ -z "$version" ]; then + echo "==> Resolving the latest ThunderID release" + version=$(curl -sSL https://api.github.com/repos/thunder-id/thunderid/releases/latest | + jq -r '.tag_name // empty' | sed 's/^v//') + if [ -z "$version" ]; then + echo "ERROR: could not resolve the latest release (rate limited?). Set THUNDERID_VERSION." >&2 + exit 1 + fi + fi + + local archive="thunderid-${version}-${pkg_os}-${pkg_arch}.zip" + local url="https://github.com/thunder-id/thunderid/releases/download/v${version}/${archive}" + local dist_home="$INSTALL_DIR/thunderid-${version}-${pkg_os}-${pkg_arch}" + + if [ ! -d "$dist_home" ]; then + echo "==> Downloading $archive" + mkdir -p "$INSTALL_DIR" + curl -sSLf "$url" -o "$INSTALL_DIR/$archive" + unzip -q "$INSTALL_DIR/$archive" -d "$INSTALL_DIR" + fi + + echo "==> Running first-time setup" + (cd "$dist_home" && ./setup.sh --admin-username "$ADMIN_USER" --admin-password "$ADMIN_PASS") + + echo "==> Starting the server" + # The server has to outlive the step that starts it, which means leaving this process group: + # `nohup` alone only ignores SIGHUP, so anything that signals the group still takes the server + # down with it. setsid does that but does not exist on macOS, so fall back to Python, whose + # start_new_session flag calls setsid in the child. + if command -v setsid >/dev/null 2>&1; then + (cd "$dist_home" && setsid ./start.sh > "$INSTALL_DIR/server.log" 2>&1 < /dev/null &) + else + python3 - "$dist_home" "$INSTALL_DIR/server.log" <<'PY' +import subprocess +import sys + +dist_home, log_path = sys.argv[1], sys.argv[2] +with open(log_path, "ab") as log: + subprocess.Popen( + ["./start.sh"], + cwd=dist_home, + stdout=log, + stderr=log, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) +PY + fi + + echo "==> Waiting for $SERVER_URL to accept connections" + # The server serves a self-signed certificate on localhost, hence -k. + local i + for i in $(seq 1 120); do + if curl -sk -o /dev/null --max-time 3 "$SERVER_URL/health/liveness"; then + echo " up" + return 0 + fi + sleep 2 + done + + echo "ERROR: the server did not come up within 240s. Last 50 log lines:" >&2 + tail -50 "$INSTALL_DIR/server.log" >&2 || true + exit 1 +} + +# --------------------------------------------------------------------------------------------- +# Admin token +# +# /applications and /users require a bearer token; the Direct-Auth-Secret header does not apply +# to them (it only gates /auth/, /register/passkey/ and /access/). Mirrors mint_admin_token() in +# the product's tests/e2e/run-e2e.sh: the CONSOLE client runs an authorization-code + PKCE +# exchange, whose credentials step is submitted over the Flow Execution API. +# --------------------------------------------------------------------------------------------- +mint_admin_token() { + local redirect_uri="$SERVER_URL/console" + local verifier challenge + verifier=$(openssl rand -hex 32 | cut -c1-43) + challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + + local headers_file location auth_id exec_id + headers_file=$(mktemp) + curl -sk -o /dev/null -D "$headers_file" \ + -G "$SERVER_URL/oauth2/authorize" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "scope=system" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ + --data-urlencode "response_type=code" \ + --data-urlencode "code_challenge=$challenge" \ + --data-urlencode "code_challenge_method=S256" + location=$(grep -i "^location:" "$headers_file" | tr -d '\r' | sed 's/^[Ll]ocation: //' || true) + rm -f "$headers_file" + + auth_id=$(sed -n 's/.*[?&]authId=\([^&]*\).*/\1/p' <<<"$location") + exec_id=$(sed -n 's/.*[?&]executionId=\([^&]*\).*/\1/p' <<<"$location") + if [ -z "$auth_id" ] || [ -z "$exec_id" ]; then + echo "ERROR: could not parse authId/executionId from the authorize redirect." >&2 + echo "Location: $location" >&2 + exit 1 + fi + + # The console login flow runs an SSO check ahead of the credentials prompt. This is a fresh, + # cookie-less login, so the first call advances past that check and mints a challenge token; + # the second submits the admin credentials with it. + local prompt_resp challenge_token flow_resp assertion + prompt_resp=$(curl -sk -X POST "$SERVER_URL/flow/execute" \ + -H "Content-Type: application/json" \ + -d "{\"executionId\": \"$exec_id\"}") + challenge_token=$(jq -r '.challengeToken // empty' <<<"$prompt_resp") + if [ -z "$challenge_token" ]; then + echo "ERROR: no challenge token. Response: $prompt_resp" >&2 + exit 1 + fi + + flow_resp=$(curl -sk -X POST "$SERVER_URL/flow/execute" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg executionId "$exec_id" \ + --arg challengeToken "$challenge_token" \ + --arg username "$ADMIN_USER" \ + --arg password "$ADMIN_PASS" \ + '{executionId: $executionId, challengeToken: $challengeToken, action: "action_001", + inputs: {username: $username, password: $password}}')") + assertion=$(jq -r '.assertion // empty' <<<"$flow_resp") + if [ -z "$assertion" ]; then + echo "ERROR: admin login returned no assertion. Response: $flow_resp" >&2 + exit 1 + fi + + local callback_resp auth_code token_resp + callback_resp=$(curl -sk -X POST "$SERVER_URL/oauth2/auth/callback" \ + -H "Content-Type: application/json" \ + -d "{\"authId\": \"$auth_id\", \"assertion\": \"$assertion\"}") + auth_code=$(jq -r '.redirect_uri // empty' <<<"$callback_resp" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p') + if [ -z "$auth_code" ]; then + echo "ERROR: callback returned no authorization code. Response: $callback_resp" >&2 + exit 1 + fi + + token_resp=$(curl -sk -X POST "$SERVER_URL/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$auth_code" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ + --data-urlencode "code_verifier=$verifier") + ADMIN_TOKEN=$(jq -r '.access_token // empty' <<<"$token_resp") + if [ -z "$ADMIN_TOKEN" ]; then + echo "ERROR: token endpoint returned no access token. Response: $token_resp" >&2 + exit 1 + fi +} + +# --------------------------------------------------------------------------------------------- +# Provision +# --------------------------------------------------------------------------------------------- +provision() { + echo "==> Obtaining admin token from $SERVER_URL" + mint_admin_token + + echo "==> Importing the E2E application" + local import_resp + import_resp=$(jq -n --arg content "$(cat "$CONFIG_FILE")" \ + '{content: $content, options: {upsert: true}}' | + curl -sk -X POST "$SERVER_URL/import" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d @-) + if [ "$(jq -r '.summary.failed // 1' <<<"$import_resp")" != "0" ]; then + echo "ERROR: import failed. Response: $import_resp" >&2 + exit 1 + fi + + # POST /import drops the attestation block (see the note in $CONFIG_FILE), so without + # this the app stores attestation: null and /flow/execute rejects the sample with FES-1016. + # Re-applying it over PUT is the only way to get devMode persisted today. + echo "==> Re-applying attestation devMode over PUT (import drops it)" + local app_json updated put_resp + app_json=$(curl -sk "$SERVER_URL/applications/$APP_ID" -H "Authorization: Bearer $ADMIN_TOKEN") + updated=$(jq '.attestation = {devMode: true}' <<<"$app_json") + put_resp=$(curl -sk -X PUT "$SERVER_URL/applications/$APP_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$updated") + if [ "$(jq -r '.attestation.devMode // false' <<<"$put_resp")" != "true" ]; then + echo "ERROR: attestation devMode did not persist. Response: $put_resp" >&2 + exit 1 + fi + + echo "==> Ensuring test user '$E2E_USER' exists" + # The filter grammar accepts exactly one `attribute eq "value"` clause. + local filter existing ou_id create_resp + filter=$(printf 'username eq "%s"' "$E2E_USER" | jq -sRr @uri) + existing=$(curl -sk "$SERVER_URL/users?filter=$filter" -H "Authorization: Bearer $ADMIN_TOKEN") + if [ "$(jq -r '(.users // []) | length' <<<"$existing")" -gt 0 ]; then + echo " already present, leaving it as is" + else + ou_id=$(curl -sk "$SERVER_URL/user-types" -H "Authorization: Bearer $ADMIN_TOKEN" | + jq -r '.types[] | select(.name == "Person") | .ouId') + if [ -z "$ou_id" ] || [ "$ou_id" = "null" ]; then + echo "ERROR: could not resolve the ouId of the Person user type." >&2 + exit 1 + fi + create_resp=$(curl -sk -X POST "$SERVER_URL/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg u "$E2E_USER" --arg p "$E2E_PASS" --arg ou "$ou_id" \ + '{ouId: $ou, type: "Person", + attributes: {username: $u, password: $p, email: ($u + "@example.com"), + given_name: "E2E", family_name: "Mobile"}}')") + if [ -z "$(jq -r '.id // empty' <<<"$create_resp")" ]; then + echo "ERROR: failed to create the test user. Response: $create_resp" >&2 + exit 1 + fi + echo " created" + fi +} + +# --------------------------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------------------------- +# Resolve a booted simulator, booting one if necessary, so a local run and a CI run take the same +# path. Which iPhone models exist depends on the installed Xcode, so prefer SIMULATOR_NAME but +# fall back to whatever iPhone is available. +resolve_simulator() { + SIM_UDID=$(xcrun simctl list devices booted | grep -oE "[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}" | head -1 || true) + if [ -n "$SIM_UDID" ]; then + echo "==> Using the booted simulator $SIM_UDID" + return 0 + fi + + local wanted="${SIMULATOR_NAME:-iPhone 17}" + SIM_UDID=$(xcrun simctl list devices available | grep -m1 "$wanted (" | + grep -oE "[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}" || true) + if [ -z "$SIM_UDID" ]; then + SIM_UDID=$(xcrun simctl list devices available | grep -m1 -E "^[[:space:]]+iPhone " | + grep -oE "[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}" || true) + fi + if [ -z "$SIM_UDID" ]; then + echo "ERROR: no available iPhone simulator." >&2 + xcrun simctl list devices available >&2 + exit 1 + fi + + echo "==> Booting simulator $SIM_UDID" + xcrun simctl boot "$SIM_UDID" + xcrun simctl bootstatus "$SIM_UDID" -b +} + +# --------------------------------------------------------------------------------------------- +# Build and install the sample +# --------------------------------------------------------------------------------------------- +build_sample() { + local sample_dir="$SCRIPT_DIR/../../Samples/Quickstart" + + echo "==> Configuring the sample" + # The sample reaches the self-signed local server because its Info.plist carries an + # NSAllowsArbitraryLoads exemption. + cat > "$sample_dir/Sources/Config.plist" < + + + + THUNDERID_BASE_URL + $SERVER_URL + THUNDERID_APPLICATION_ID + $APP_ID + + +PLIST + + echo "==> Building and installing the sample" + (cd "$sample_dir" && xcodebuild \ + -project Quickstart.xcodeproj \ + -scheme Quickstart \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$SIM_UDID" \ + -derivedDataPath .build/e2e-derived \ + build) + xcrun simctl install "$SIM_UDID" \ + "$sample_dir/.build/e2e-derived/Build/Products/Debug-iphonesimulator/Quickstart.app" +} + +# --------------------------------------------------------------------------------------------- +# Run the flows +# --------------------------------------------------------------------------------------------- +run_flows() { + command -v maestro >/dev/null 2>&1 || { + echo "ERROR: maestro is not installed. See https://maestro.mobile.dev/getting-started/installing-maestro" >&2 + exit 1 + } + # Default to the whole suite when no flow path was passed through. Checking the length before + # expanding matters: under `set -u`, bash 3.2 (still the default on macOS) treats expanding an + # empty array as an unbound variable. + local target + if [ "${#maestro_args[@]}" -eq 0 ]; then + target=("flows/") + else + target=("${maestro_args[@]}") + fi + + echo "==> Running Maestro" + # The JUnit report is what makes a failed run readable without scraping the console log; it + # sits alongside Maestro's own debug output and CI collects both. + (cd "$SCRIPT_DIR" && maestro --device "$SIM_UDID" test "${target[@]}" \ + --format=JUNIT \ + --output=report.xml \ + -e E2E_USERNAME="$E2E_USER" \ + -e E2E_PASSWORD="$E2E_PASS") +} + +if $do_server; then + start_server + provision + echo + echo " server : $SERVER_URL" + echo " application id : $APP_ID" + echo " test user : $E2E_USER" + echo +fi +if $do_build || $do_test; then resolve_simulator; fi +$do_build && build_sample +$do_test && run_flows