Skip to content

Commit 24cc9d8

Browse files
feat: add with-app-integrity example (#699)
This PR implements a `with-app-integrity` example into the expo-examples repository, creating an example with [App Integrity](https://docs.expo.dev/versions/latest/sdk/app-integrity/) - ensuring the backend resources are accessed only by legitimate installations of an app running on genuine devices. Demo: https://github.com/user-attachments/assets/4968e98d-f6e2-49ff-b014-78f6573dfdd2
1 parent 3aea0c4 commit 24cc9d8

14 files changed

Lines changed: 579 additions & 0 deletions

with-app-integrity/.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Client (public) — Google Cloud project number for Play Integrity.
2+
EXPO_PUBLIC_CLOUD_PROJECT_NUMBER=""
3+
# Optional — point the client at a deployed server (defaults to the dev server).
4+
EXPO_PUBLIC_API_URL=""
5+
6+
# Google Play Integrity (server) — path to the service account JSON.
7+
GOOGLE_APPLICATION_CREDENTIALS=""
8+
GOOGLE_PLAY_APP_PACKAGE_NAME=""
9+
10+
# Apple App Attest (server).
11+
IOS_BUNDLE_IDENTIFIER=""
12+
IOS_TEAM_IDENTIFIER=""
13+
IOS_ALLOW_DEVELOPMENT_ENVIRONMENT=""

with-app-integrity/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# App Integrity Example
2+
3+
<p>
4+
<!-- iOS -->
5+
<img alt="Supports Expo iOS" longdesc="Supports Expo iOS" src="https://img.shields.io/badge/iOS-4630EB.svg?style=flat-square&logo=APPLE&labelColor=999999&logoColor=fff" />
6+
<!-- Android -->
7+
<img alt="Supports Expo Android" longdesc="Supports Expo Android" src="https://img.shields.io/badge/Android-4630EB.svg?style=flat-square&logo=ANDROID&labelColor=A4C639&logoColor=fff" />
8+
</p>
9+
10+
Verify that requests come from a genuine, untampered app on a real device using [Expo App Integrity](https://docs.expo.dev/versions/latest/sdk/app-integrity/)[App Attest](https://developer.apple.com/documentation/devicecheck) on iOS and [Play Integrity](https://developer.android.com/google/play/integrity) on Android — with server-side verification via Expo Router API routes.
11+
12+
## Launch your own
13+
14+
[![Launch with Expo](https://github.com/expo/examples/blob/master/.gh-assets/launch.svg?raw=true)](https://launch.expo.dev/?github=https://github.com/expo/examples/tree/master/with-app-integrity)
15+
16+
## 🚀 How to use
17+
18+
- Install packages with `yarn` or `npm install`.
19+
- If you have native iOS code run `npx pod-install`
20+
- Copy `.env.example` to `.env` and add your Apple and Google credentials.
21+
- Run `yarn start` or `npm run start` to start the bundler (it also serves the API routes on Node).
22+
- Open the project in a development build on a real device to try it. App Attest and Play Integrity don't work in Expo Go or on simulators/emulators.
23+
- Set `EXPO_PUBLIC_API_URL` to a URL the device can reach (your machine's LAN URL, or a deployed server).
24+
25+
### 📁 File Structure
26+
27+
```
28+
with-app-integrity
29+
├── app
30+
│ ├── _layout.tsx ➡️ Expo Router root stack
31+
│ ├── index.tsx ➡️ Client UI + attestation/assertion flow
32+
│ └── api
33+
│ ├── challenge+api.ts ➡️ Issues one-time challenges
34+
│ └── verify+api.ts ➡️ Verifies attestations, assertions, and tokens
35+
├── helpers
36+
│ ├── store.ts ➡️ In-memory challenge & attested-key store
37+
│ ├── verify-ios.ts ➡️ App Attest verification (node-app-attest)
38+
│ ├── verify-android.ts ➡️ Play Integrity verification (@googleapis/playintegrity)
39+
│ └── is-valid-android-request.ts ➡️ Play Integrity verdict checks
40+
├── app.config.js ➡️ Expo config file
41+
└── .env.example ➡️ Apple & Google credentials template
42+
```
43+
44+
## 📝 Notes
45+
46+
- Learn more about [Expo App Integrity](https://docs.expo.dev/versions/latest/sdk/app-integrity/).
47+
- Learn more about [Development Builds](https://docs.expo.dev/develop/development-builds/create-a-build/)
48+
- Server verification uses [node-app-attest](https://github.com/uebelack/node-app-attest) (iOS) and [@googleapis/playintegrity](https://www.npmjs.com/package/@googleapis/playintegrity) (Android).

with-app-integrity/app.config.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** @type {import('expo/config').ExpoConfig} */
2+
module.exports = {
3+
name: "with-app-integrity",
4+
slug: "with-app-integrity",
5+
scheme: "withappintegrity",
6+
icon: "https://github.com/expo/expo/blob/master/templates/expo-template-blank/assets/icon.png?raw=true",
7+
splash: {
8+
image: "https://github.com/expo/expo/blob/master/templates/expo-template-blank/assets/splash.png?raw=true",
9+
resizeMode: "contain",
10+
backgroundColor: "#ffffff",
11+
},
12+
ios: {
13+
bundleIdentifier: "com.example.withappintegrity",
14+
},
15+
android: {
16+
package: "com.example.withappintegrity",
17+
},
18+
web: {
19+
bundler: "metro",
20+
output: "server",
21+
},
22+
plugins: ["expo-router", "expo-secure-store"],
23+
};

with-app-integrity/app/_layout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Stack } from "expo-router";
2+
3+
export default function RootLayout() {
4+
return (
5+
<Stack screenOptions={{ headerTitle: "App Integrity" }}>
6+
<Stack.Screen name="index" />
7+
</Stack>
8+
);
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createChallenge } from "../../helpers/store";
2+
3+
// GET /api/challenge -> { challenge }
4+
//
5+
// The client sends `challenge` to App Attest / Play Integrity, then returns the
6+
// same `challenge` to /api/verify, which validates and consumes it.
7+
export function GET() {
8+
return Response.json(createChallenge());
9+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { consumeChallenge } from "../../helpers/store";
2+
import { verifyAndroidToken } from "../../helpers/verify-android";
3+
import { verifyIosAssertion, verifyIosAttestation } from "../../helpers/verify-ios";
4+
5+
// POST /api/verify
6+
// Body: { kind, challenge, ...platform-specific fields }
7+
export async function POST(request: Request) {
8+
try {
9+
const body = await request.json();
10+
const { kind, challenge } = body;
11+
12+
if (!challenge || !consumeChallenge(challenge)) {
13+
return Response.json({ error: "challenge invalid or expired" }, { status: 400 });
14+
}
15+
16+
switch (kind) {
17+
case "ios-attest":
18+
return Response.json(
19+
await verifyIosAttestation({
20+
keyId: body.keyId,
21+
attestation: body.attestation,
22+
challenge,
23+
}),
24+
);
25+
case "ios-assert":
26+
return Response.json(
27+
await verifyIosAssertion({
28+
keyId: body.keyId,
29+
assertion: body.assertion,
30+
challenge,
31+
}),
32+
);
33+
case "android":
34+
return Response.json(await verifyAndroidToken({ token: body.token, challenge }));
35+
default:
36+
return Response.json({ error: `unknown kind: ${kind}` }, { status: 400 });
37+
}
38+
} catch (error) {
39+
console.error("verify error:", error);
40+
return Response.json({ error: error instanceof Error ? error.message : "verification failed" }, { status: 400 });
41+
}
42+
}

with-app-integrity/app/index.tsx

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import * as AppIntegrity from "@expo/app-integrity";
2+
import * as SecureStore from "expo-secure-store";
3+
import { useEffect, useState } from "react";
4+
import { Button, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
5+
import { useSafeAreaInsets } from "react-native-safe-area-context";
6+
7+
// Public URL of the deployed Node server running the API routes. Leave empty to
8+
// use the dev server (relative requests). Required for device/production builds.
9+
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "";
10+
const CLOUD_PROJECT_NUMBER = process.env.EXPO_PUBLIC_CLOUD_PROJECT_NUMBER ?? "";
11+
12+
// SecureStore key under which the attested App Attest key identifier is kept.
13+
const KEY_ID_STORE = "appattest.keyId";
14+
15+
async function getChallenge(): Promise<{ challenge: string }> {
16+
const res = await fetch(`${API_URL}/api/challenge`);
17+
if (!res.ok) throw new Error(`challenge failed: ${res.status}`);
18+
return res.json();
19+
}
20+
21+
async function verify(body: Record<string, unknown>) {
22+
const res = await fetch(`${API_URL}/api/verify`, {
23+
method: "POST",
24+
headers: { "Content-Type": "application/json" },
25+
body: JSON.stringify(body),
26+
});
27+
const json = await res.json();
28+
if (!res.ok) throw new Error(json.error ?? `verify failed: ${res.status}`);
29+
return json;
30+
}
31+
32+
export default function Index() {
33+
const insets = useSafeAreaInsets();
34+
const [log, setLog] = useState<string[]>([]);
35+
const [busy, setBusy] = useState(false);
36+
// The attested keyId, restored from SecureStore. When set, assertions are
37+
// available without re-attesting.
38+
const [keyId, setKeyId] = useState<string | null>(null);
39+
40+
useEffect(() => {
41+
if (Platform.OS === "ios") {
42+
SecureStore.getItemAsync(KEY_ID_STORE)
43+
.then(setKeyId)
44+
.catch(() => {});
45+
}
46+
}, []);
47+
48+
const append = (line: string) => setLog((prev) => [...prev, line]);
49+
50+
async function run(fn: () => Promise<void>) {
51+
setBusy(true);
52+
setLog([]);
53+
try {
54+
await fn();
55+
} catch (e) {
56+
append(`❌ ${e instanceof Error ? e.message : String(e)}`);
57+
} finally {
58+
setBusy(false);
59+
}
60+
}
61+
62+
async function runAttestation() {
63+
append(`▶️ Attesting on ${Platform.OS}…`);
64+
if (!AppIntegrity.isSupported) {
65+
append("❌ App Attest is not supported on this device.");
66+
return;
67+
}
68+
69+
const newKeyId = await AppIntegrity.generateKeyAsync();
70+
append(`🔑 keyId: ${newKeyId}`);
71+
72+
const { challenge } = await getChallenge();
73+
const attestation = await AppIntegrity.attestKeyAsync(newKeyId, challenge);
74+
append("📤 sending attestation to server…");
75+
const result = await verify({
76+
kind: "ios-attest",
77+
challenge,
78+
keyId: newKeyId,
79+
attestation,
80+
});
81+
append(`✅ attestation verified: ${JSON.stringify(result)}`);
82+
83+
// Persist the attested key so assertions can use it later.
84+
await SecureStore.setItemAsync(KEY_ID_STORE, newKeyId);
85+
setKeyId(newKeyId);
86+
append("💾 keyId saved to SecureStore");
87+
}
88+
89+
async function runAssertion() {
90+
const storedKeyId = await SecureStore.getItemAsync(KEY_ID_STORE);
91+
if (!storedKeyId) {
92+
append("❌ No attested key found. Run attestation first.");
93+
setKeyId(null);
94+
return;
95+
}
96+
append(`🔑 using stored keyId: ${storedKeyId}`);
97+
98+
const { challenge } = await getChallenge();
99+
const assertion = await AppIntegrity.generateAssertionAsync(storedKeyId, challenge);
100+
append("📤 sending assertion to server…");
101+
const result = await verify({
102+
kind: "ios-assert",
103+
challenge,
104+
keyId: storedKeyId,
105+
assertion,
106+
});
107+
append(`✅ assertion verified: ${JSON.stringify(result)}`);
108+
}
109+
110+
async function runAndroid() {
111+
append("▶️ Running integrity check on android…");
112+
if (!CLOUD_PROJECT_NUMBER) {
113+
append("❌ Set EXPO_PUBLIC_CLOUD_PROJECT_NUMBER in your .env first.");
114+
return;
115+
}
116+
await AppIntegrity.prepareIntegrityTokenProviderAsync(CLOUD_PROJECT_NUMBER);
117+
append("⚙️ integrity token provider ready");
118+
119+
const { challenge } = await getChallenge();
120+
const token = await AppIntegrity.requestIntegrityCheckAsync(challenge);
121+
append("📤 sending integrity token to server…");
122+
const result = await verify({ kind: "android", challenge, token });
123+
append(`✅ token verified: ${JSON.stringify(result)}`);
124+
}
125+
126+
return (
127+
<View style={[styles.container, { paddingBottom: insets.bottom + 16 }]}>
128+
<Text style={styles.title}>Device & app attestation</Text>
129+
<Text style={styles.subtitle}>Run the platform integrity flow, then verify the result on the server.</Text>
130+
131+
<View style={styles.buttons}>
132+
{Platform.OS === "android" ? (
133+
<Button title={busy ? "Running…" : "Run integrity check"} onPress={() => run(runAndroid)} disabled={busy} />
134+
) : (
135+
<>
136+
<Button title={busy ? "Running…" : "Run attestation"} onPress={() => run(runAttestation)} disabled={busy} />
137+
{keyId ? (
138+
<Button title={busy ? "Running…" : "Run assertion"} onPress={() => run(runAssertion)} disabled={busy} />
139+
) : null}
140+
</>
141+
)}
142+
</View>
143+
144+
<ScrollView style={styles.log} contentContainerStyle={styles.logContent}>
145+
{log.length === 0 ? (
146+
<Text style={styles.placeholder}>Results will appear here.</Text>
147+
) : (
148+
log.map((line, i) => (
149+
<Text key={i} style={styles.logLine} selectable>
150+
{line}
151+
</Text>
152+
))
153+
)}
154+
</ScrollView>
155+
</View>
156+
);
157+
}
158+
159+
const styles = StyleSheet.create({
160+
container: {
161+
flex: 1,
162+
padding: 16,
163+
gap: 12,
164+
backgroundColor: "#fff",
165+
},
166+
title: {
167+
fontSize: 22,
168+
fontWeight: "600",
169+
},
170+
subtitle: {
171+
fontSize: 14,
172+
color: "#555",
173+
},
174+
buttons: {
175+
gap: 8,
176+
},
177+
log: {
178+
flex: 1,
179+
borderWidth: 1,
180+
borderColor: "#e0e0e0",
181+
borderRadius: 8,
182+
},
183+
logContent: {
184+
padding: 12,
185+
gap: 6,
186+
},
187+
placeholder: {
188+
color: "#999",
189+
},
190+
logLine: {
191+
fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }),
192+
fontSize: 12,
193+
},
194+
});

with-app-integrity/expo-env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/// <reference types="expo/types" />
2+
3+
// NOTE: This file should not be edited and should be in your git ignore.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { playintegrity_v1 } from "@googleapis/playintegrity";
2+
3+
// Validates a decoded Play Integrity verdict against the original request.
4+
// Ported from the reference push-notifications-service.
5+
export function isValidAndroidRequest(
6+
requestDetails: playintegrity_v1.Schema$RequestDetails | null | undefined,
7+
appIntegrity: playintegrity_v1.Schema$AppIntegrity | null | undefined,
8+
deviceIntegrity: playintegrity_v1.Schema$DeviceIntegrity | null | undefined,
9+
packageName: string,
10+
challenge: string,
11+
): boolean {
12+
if (!requestDetails || !appIntegrity || !deviceIntegrity) {
13+
return false;
14+
}
15+
16+
const timestampMillis =
17+
typeof requestDetails.timestampMillis === "string"
18+
? parseInt(requestDetails.timestampMillis, 10)
19+
: requestDetails.timestampMillis;
20+
21+
return (
22+
requestDetails.requestPackageName?.toLowerCase() === packageName.toLowerCase() &&
23+
typeof timestampMillis === "number" &&
24+
!isNaN(timestampMillis) &&
25+
Date.now() - timestampMillis < 120000 &&
26+
requestDetails.requestHash === challenge &&
27+
appIntegrity.appRecognitionVerdict === "PLAY_RECOGNIZED" &&
28+
(deviceIntegrity.deviceRecognitionVerdict?.includes("MEETS_DEVICE_INTEGRITY") ?? false)
29+
);
30+
}

0 commit comments

Comments
 (0)