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
83 changes: 83 additions & 0 deletions .github/actions/run-e2e-suite/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Runs the Quickstart E2E suite: a ThunderID server, the provisioned test application and user,
# the sample built onto an emulator, 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: ☕ Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4
with:
distribution: temurin
java-version: "17"

- name: 🗄️ Cache Gradle
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-

- 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: 🖥️ Enable KVM
shell: bash
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: 🔬 Run E2E Suite
uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2
env:
THUNDERID_VERSION: ${{ inputs.thunderid-version }}
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
# The script starts at the repository root.
script: cd tests/e2e && ./run-e2e.sh

- name: 📤 Upload Debug Artifacts
if: ${{ failure() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-debug-android${{ 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: ubuntu-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: ubuntu-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
12 changes: 12 additions & 0 deletions .github/workflows/pr-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,15 @@ jobs:
- name: 🔨 Build Quickstart Sample
working-directory: samples/quickstart
run: ./gradlew build -x test

e2e:
name: 🎭 E2E Tests
if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: 🔬 Run E2E Suite
uses: ./.github/actions/run-e2e-suite
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ google-services.json

# Android Profiling
*.hprof

# ThunderID server distribution downloaded by the E2E suite
.thunderid-server/

# Maestro JUnit report from the E2E suite.
tests/e2e/report.xml
87 changes: 7 additions & 80 deletions samples/quickstart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,86 +5,13 @@ ThunderID Android Quickstart demonstrates the full authentication lifecycle usin
**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

- Android Studio 2022.3+
- A running ThunderID instance

## Setup

```bash
cp config.properties.example config.properties
```


### 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_APPLICATION_ID` | Application UUID from ThunderID console |

💡 `config.properties` is gitignored. Never commit real credentials.

> [!NOTE]
> If your ThunderID server is running on `localhost`, don't use `localhost` in `THUNDERID_BASE_URL` directly:
> - **Emulator**: use `https://10.0.2.2:8090` — the emulator's alias for your host machine's loopback.
> - **Physical device**: run `adb reverse tcp:8090 tcp:8090` to forward the port over USB and keep using
> `https://localhost:8090`, or point the URL at your host machine's LAN IP.

### Attestation via Google Play Integrity (optional)

If the application enforces Google Play Integrity attestation, set `THUNDERID_ATTESTATION_ENABLED=true` and
`THUNDERID_CLOUD_PROJECT_NUMBER` to the number (not the ID) of the Google Cloud project linked to your Play
Console app, then rebuild. When enabled, the sample mints a token via `PlayIntegrityTokenProvider` (Play
Integrity Standard API) and sends it with every native flow-initiate request.

Testing this end-to-end requires:
- The app uploaded to a Play Console listing (an internal testing track is enough) with your test device's
Google account added as a tester, so Play recognizes the package name and signing certificate.
- The Play Integrity API enabled on the linked Google Cloud project.
- A release build signed with the certificate registered on the ThunderID application's attestation config
(`certificateSha256Digests`) — a debug-signed APK will fail the signing-identity check.

### Passkeys (WebAuthn)

Passkey registration/authentication via Jetpack Credential Manager
(`CreatePublicKeyCredentialRequest`/`GetPublicKeyCredentialOption` in `PasskeyClient`) requires the
server's passkey relying party (`rp.id`) to be a real HTTPS domain, not `localhost` or `10.0.2.2`.
Android verifies the caller is allowed to use that `rp.id` via **Digital Asset Links**: the domain
must serve `https://<domain>/.well-known/assetlinks.json` declaring this app's package name and
signing certificate SHA-256 fingerprint(s) under `delegate_permission/common.get_login_creds`.
Without this, Credential Manager rejects the ceremony.

This sample ships `assetlinks.json.example` with a placeholder
`sha256_cert_fingerprints` entry for the sample's `applicationId`
(`dev.thunderid.Quickstart`). To exercise passkeys end-to-end:

1. Rename/copy `assetlinks.json.example` to `assetlinks.json` and replace
`<YOUR_APP_SIGNING_CERT_SHA256_FINGERPRINT>` with the SHA-256 fingerprint of the signing
certificate for the build you'll test with (get it via
`keytool -list -v -keystore <your.keystore> -alias <alias>` or
`./gradlew signingReport` for a debug build).
2. Host that file at `https://<your-thunderid-domain>/.well-known/assetlinks.json` — the domain
must serve valid HTTPS (self-signed certs will not work).
3. Make sure the server's passkey `rp.id` matches that same domain — the SDK's `PasskeyClient`
passes whatever `rp.id`/relying-party options the server returns straight through to Credential
Manager.
4. No `AndroidManifest.xml` change is required for this — unlike iOS's Associated Domains
entitlement, Digital Asset Links verification is purely a server-hosted file requirement and
does not need an App Links intent filter (this sample doesn't declare one).

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 template file/documentation, not the tunnel itself.

## Run

Open in Android Studio, sync Gradle, and run on an API 24+ emulator or device.
See [Try the Android Sample App](https://thunderid.dev/docs/v1.0.x/sdks/android/guides/try-the-sample-app)
in the Android SDK docs for prerequisites, configuration, attestation, passkeys, and run instructions.
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ fun AuthScreen(applicationId: String) {
) {
when (showSheet) {
"login" -> LoginSheetContent(applicationId = applicationId)
"signup" -> SignUpSheetContent()
"signup" -> SignUpSheetContent(onComplete = { showSheet = null })
}
}
}
Expand Down Expand Up @@ -212,7 +212,7 @@ private fun LoginSheetContent(applicationId: String) {
}

@Composable
private fun SignUpSheetContent() {
private fun SignUpSheetContent(onComplete: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
Expand All @@ -222,7 +222,14 @@ private fun SignUpSheetContent() {
) {
SheetTitle("Create account")
Spacer(Modifier.height(8.dp))
SignUp(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp))
// Registration finishes without establishing a session, so the app stays on AuthScreen and
// this sheet is not torn down with it the way the sign-in sheet is (a successful sign-in
// swaps the whole screen for HomeScreen). Without closing it here the completed flow
// leaves an empty sheet covering the app.
SignUp(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
onComplete = onComplete,
)
}
}

Expand Down
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 Android 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
39 changes: 37 additions & 2 deletions src/main/kotlin/dev/thunderid/android/http/HttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,25 @@ internal class HttpClient(
}
val connection =
(URL(urlString).openConnection() as HttpURLConnection).apply {
// The bypass is deliberately limited to loopback. Its only legitimate use is
// reaching a development server through the self-signed certificate ThunderID
// generates for localhost, and an attacker cannot sit in the middle of a
// loopback connection. Honouring the flag for arbitrary hosts would turn a
// convenience switch into a full man-in-the-middle hole on the channel
// carrying credentials, assertions and refresh tokens, in any build that
// happened to ship with it enabled.
if (allowInsecureConnections && this is HttpsURLConnection) {
sslSocketFactory = insecureSslSocketFactory()
hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
if (isLoopbackHost(URL(urlString).host)) {
sslSocketFactory = insecureSslSocketFactory()
hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
} else {
throw IAMException(
ThunderIDErrorCode.INVALID_CONFIGURATION,
"allowInsecureConnections only applies to loopback hosts " +
"(localhost, 127.0.0.1, ::1, 10.0.2.2); refusing to disable " +
"certificate validation for '${URL(urlString).host}'",
)
}
}
requestMethod = method
setRequestProperty("Content-Type", "application/json")
Expand Down Expand Up @@ -128,6 +144,14 @@ internal class HttpClient(
.fromJson(body, T::class.java)
}

/**
* Whether [host] is a loopback address, and therefore unreachable by a network attacker.
*
* `10.0.2.2` is included because that is the Android emulator's alias for the host machine's
* loopback interface, which is how an emulator reaches a development server.
*/
private fun isLoopbackHost(host: String?): Boolean = host in LOOPBACK_HOSTS

private fun insecureSslSocketFactory(): javax.net.ssl.SSLSocketFactory {
val trustAll =
object : X509TrustManager {
Expand All @@ -147,4 +171,15 @@ internal class HttpClient(
ctx.init(null, arrayOf<TrustManager>(trustAll), SecureRandom())
return ctx.socketFactory
}

private companion object {
/**
* Hosts a network attacker cannot occupy, and therefore the only ones for which
* certificate validation may be relaxed.
*
* `10.0.2.2` is the Android emulator's alias for the host machine's loopback interface,
* which is how an emulator reaches a development server.
*/
val LOOPBACK_HOSTS = setOf("localhost", "127.0.0.1", "::1", "[::1]", "10.0.2.2")
}
}
Loading
Loading