Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/actions/run-e2e-suite/action.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions .github/workflows/pr-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 7 additions & 72 deletions Samples/Quickstart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<TeamID>.<BundleID>` 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://<that-domain>/.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.
34 changes: 34 additions & 0 deletions Samples/Quickstart/thunderid-config/thunderid-config.yaml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions Sources/ThunderID/http/LocalhostPinnedURLSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions Tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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_<timestamp>`.

## 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.
6 changes: 6 additions & 0 deletions Tests/e2e/flows/config.yaml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading