reis-mobile 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +37 -19
  5. package/agents/android-architect.md +3 -3
  6. package/agents/android-performance-engineer.md +1 -1
  7. package/agents/flutter-architect.md +3 -3
  8. package/agents/flutter-performance-engineer.md +1 -1
  9. package/agents/ios-architect.md +3 -3
  10. package/agents/ios-performance-engineer.md +1 -1
  11. package/agents/kmp-architect.md +3 -3
  12. package/agents/kmp-performance-engineer.md +1 -1
  13. package/agents/lead-mobile.md +3 -0
  14. package/agents/mobile-release-engineer.md +3 -3
  15. package/agents/mobile-staff-engineer.md +1 -1
  16. package/agents/rn-architect.md +3 -3
  17. package/agents/rn-performance-engineer.md +1 -1
  18. package/core/router/intents.mjs +15 -0
  19. package/package.json +1 -1
  20. package/skills/mobile-accessibility/SKILL.md +3 -0
  21. package/skills/mobile-accessibility/references/localization.md +77 -0
  22. package/skills/mobile-observability/SKILL.md +137 -0
  23. package/skills/mobile-observability/references/android.md +105 -0
  24. package/skills/mobile-observability/references/flutter.md +86 -0
  25. package/skills/mobile-observability/references/ios.md +88 -0
  26. package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
  27. package/skills/mobile-observability/references/react-native.md +62 -0
  28. package/skills/mobile-offline-sync/SKILL.md +132 -0
  29. package/skills/mobile-offline-sync/references/android.md +123 -0
  30. package/skills/mobile-offline-sync/references/flutter.md +103 -0
  31. package/skills/mobile-offline-sync/references/ios.md +103 -0
  32. package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
  33. package/skills/mobile-offline-sync/references/react-native.md +108 -0
  34. package/skills/mobile-performance/SKILL.md +88 -0
  35. package/skills/mobile-performance/references/android.md +96 -0
  36. package/skills/mobile-performance/references/background.md +87 -0
  37. package/skills/mobile-performance/references/flutter.md +101 -0
  38. package/skills/mobile-performance/references/ios.md +82 -0
  39. package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
  40. package/skills/mobile-performance/references/react-native.md +85 -0
  41. package/skills/mobile-release/SKILL.md +2 -1
  42. package/skills/mobile-release/references/billing.md +92 -0
  43. package/skills/mobile-security/SKILL.md +3 -0
  44. package/skills/mobile-security/references/auth.md +92 -0
@@ -0,0 +1,62 @@
1
+ # Performance in Kotlin Multiplatform
2
+
3
+ A KMP performance problem is either **shared Kotlin cost**, which shows up on both targets, or a **target-specific consequence** — the Kotlin/Native binary on iOS, the interop boundary, or the host UI. Deciding which comes first is the whole job: optimizing shared code that is not the bottleneck is wasted work, and so is profiling the Android host for a cost that lives in `commonMain`.
4
+
5
+ Related material already in this plugin: [`mobile-kmp/references/build-performance.md`](../../mobile-kmp/references/build-performance.md) and [`source-sets.md`](../../mobile-kmp/references/source-sets.md).
6
+
7
+ ## 1. Split shared cost from host cost
8
+
9
+ Run the same journey on both hosts and compare:
10
+
11
+ | Observation | Where the cost lives |
12
+ |---|---|
13
+ | Slow on Android and iOS, similar magnitude | Shared Kotlin: algorithm, serialization, database access in `commonMain` |
14
+ | Slow only on iOS | Kotlin/Native: memory management, the Swift/Kotlin bridge, a suspend function crossing into Swift, framework initialization |
15
+ | Slow only on Android | The Android host: Compose recomposition, main-thread work in the ViewModel, `ContentProvider` initializers |
16
+ | Both slow, different symptoms | Two problems. Fix them separately |
17
+
18
+ Then profile with the host's own tools — there is no KMP profiler. Android Studio Profiler and Macrobenchmark for the Android host ([android.md](android.md)); Instruments for the iOS host ([ios.md](ios.md)), where Kotlin frames appear once the framework's dSYM is available to the trace.
19
+
20
+ Benchmark the shared code directly where you can: `kotlinx-benchmark` runs the same benchmark on JVM and Native targets, which is the only way to compare the two compilers' output for your algorithm rather than guessing.
21
+
22
+ ## 2. The interop boundary
23
+
24
+ Every call from Swift into the generated framework crosses a bridge. One call per frame is free; one call per list row per frame is not.
25
+
26
+ - **Batch the crossing.** Return a whole page of prepared view state in one call instead of exposing a getter per field. The shared layer should expose data already shaped for the screen.
27
+ - **Suspend functions and `Flow` become callbacks in Swift.** Each emission crosses the boundary and hops a dispatcher. A `Flow` that emits per keystroke from shared code into SwiftUI is a per-keystroke bridge crossing plus a SwiftUI update — debounce or conflate in shared code, not in Swift.
28
+ - **Collections cross by conversion.** A Kotlin `List` handed to Swift is bridged; a large list converted on every access is a hidden O(n) per call. Hold the reference, do not re-fetch.
29
+ - **Objective-C interop erases generics and sealed hierarchies**, which pushes Swift callers into exhaustive `switch`es over erased types and sometimes into defensive copies. Design a flat, explicitly-typed API surface at the boundary.
30
+
31
+ ## 3. Kotlin/Native specifics
32
+
33
+ - The current Kotlin/Native memory manager removes the old freezing rules, so shared mutable state across threads is allowed — but objects still cross into Objective-C autorelease pools, and retain cycles between a Kotlin object and a Swift closure leak exactly like they do in pure Swift. Break them with `[weak self]` on the Swift side and explicit cancellation on the Kotlin side.
34
+ - **Framework initialization is launch cost on iOS.** Anything in a Kotlin top-level property with an initializer, or in a Koin/DI module built at framework load, runs before your first screen. Keep it lazy.
35
+ - **Framework size** counts against the app's download size. Check `isStatic`, dead-code elimination and whether you export more modules than the host actually calls (`export(...)` in the framework block pulls whole modules into the public API and prevents their elimination).
36
+ - Release builds only. Kotlin/Native debug binaries are dramatically slower than release ones, and a "KMP is slow" report measured on a debug framework is a measurement bug.
37
+
38
+ ## 4. Shared code that is usually the bottleneck
39
+
40
+ - **Serialization.** `kotlinx.serialization` over a large payload on the main dispatcher blocks both hosts. Parse on `Dispatchers.Default`, and parse once — not per screen.
41
+ - **Database access.** SQLDelight queries on the calling thread; map rows lazily and do not materialize a whole table to display twenty rows.
42
+ - **Flow operators.** An unconfined chain of `map`/`combine` re-running on every upstream emission multiplies work across the boundary. `distinctUntilChanged`, `conflate` and `stateIn` with a sharing policy bound to the host lifecycle keep it bounded.
43
+ - **`expect`/`actual` asymmetry.** A cheap JVM implementation and an expensive Native one for the same declaration produces the "slow only on iOS" case above. Measure both actuals.
44
+
45
+ ## 5. Compose Multiplatform UI
46
+
47
+ When the UI is shared, the Compose performance rules in [android.md](android.md) apply unchanged — stability of parameters, deferred state reads, lazy layouts, `derivedStateOf`. What changes is measurement: the Android host gives you Layout Inspector recomposition counts and composition tracing; on iOS you have Instruments' time profile with Kotlin frames and no recomposition counter, so validate recomposition behaviour on Android and confirm the frame result on iOS.
48
+
49
+ ## 6. Regression detection in CI
50
+
51
+ - Macrobenchmark on the Android host guards startup and frame time for the shared stack ([android.md](android.md)).
52
+ - `XCTApplicationLaunchMetric` on the iOS host guards framework initialization cost ([ios.md](ios.md)).
53
+ - `kotlinx-benchmark` on JVM and Native guards the shared algorithms themselves, and is the only gate that fails for a shared-code regression before either host notices.
54
+ - Track the produced framework size and the Android AAB size per build; an `export(...)` added to the framework block can move both without any UI change.
55
+
56
+ ## Official documentation
57
+
58
+ - Kotlin Multiplatform documentation: https://kotlinlang.org/docs/multiplatform.html
59
+ - Kotlin/Native memory management: https://kotlinlang.org/docs/native-memory-manager.html
60
+ - Swift/Objective-C interop: https://kotlinlang.org/docs/native-objc-interop.html
61
+ - kotlinx-benchmark: https://github.com/Kotlin/kotlinx-benchmark
62
+ - Compose Multiplatform: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-and-jetpack-compose.html
@@ -0,0 +1,85 @@
1
+ # Performance in React Native and Expo
2
+
3
+ A React Native frame crosses three execution contexts: the **JavaScript thread** (your React code), the **native UI thread**, and — with the New Architecture — the shadow tree and Fabric's rendering pipeline. A slow app is slow in one of them, and the fix differs. Identify the thread first.
4
+
5
+ Related material already in this plugin: [`mobile-rn/references/performance-release.md`](../../mobile-rn/references/performance-release.md).
6
+
7
+ ## Measure
8
+
9
+ **Release build, physical device, Hermes enabled.** A Metro dev build carries the dev bundle, React strict-mode double renders, and the bridge dev listeners; its numbers are not the shipped app's.
10
+
11
+ ```bash
12
+ npx react-native run-android --mode=release
13
+ npx expo run:ios --configuration Release
14
+ ```
15
+
16
+ | Question | Tool |
17
+ |---|---|
18
+ | Which React components render, and why | React DevTools Profiler (React Native DevTools), "Highlight updates" |
19
+ | JavaScript CPU time | Hermes sampling profiler via React Native DevTools → Performance, or `.cpuprofile` capture |
20
+ | Frame drops and native thread cost | The platform tools: Perfetto/system trace on Android, Instruments on iOS (see [android.md](android.md) and [ios.md](ios.md)) |
21
+ | Startup cost breakdown | `performance.now()` marks around module require and first render; on Android, `adb shell am start -W` for the whole process |
22
+ | Bundle size and what is in it | `npx react-native bundle --platform android --dev false --sourcemap-output bundle.map` then a source-map explorer; `npx expo export` for Expo |
23
+
24
+ Instrument your own spans, and keep them in release behind a flag, so the same marks exist in the field:
25
+
26
+ ```js
27
+ const start = performance.now();
28
+ const parsed = parseOrders(payload);
29
+ track('parse_orders_ms', performance.now() - start);
30
+ ```
31
+
32
+ ## The JavaScript thread
33
+
34
+ - **Re-renders are the default problem.** A context value recreated each render, an inline object/array/function prop, or a parent that re-renders on every keystroke will cascade. `React.memo` on the child plus `useCallback`/`useMemo` on the props, or moving the state down so the parent is not involved at all.
35
+ - **Selectors, not whole stores.** Subscribing to an entire Zustand/Redux store re-renders on every change; subscribe to the slice. With React Query/TanStack Query, `select` narrows what a component observes.
36
+ - **Never block JS.** JSON parsing of a large payload, sorting thousands of rows, crypto, image manipulation — these freeze every touch handler. Move them to the native side, a worklet, or chunk them across frames with `InteractionManager.runAfterInteractions` and batched state updates.
37
+ - **Text input and animation state** should not live in React state when they change per frame. Use `Animated` with `useNativeDriver: true`, or Reanimated shared values, so the animation runs on the UI thread and never touches JS.
38
+
39
+ ## Lists
40
+
41
+ `FlatList`/`SectionList` are virtualized but still render through React. The props that matter:
42
+
43
+ | Prop | Why |
44
+ |---|---|
45
+ | `keyExtractor` | Stable keys; without them React remounts rows on any change |
46
+ | `getItemLayout` | Skips measurement, enables instant `scrollToIndex` — only valid for fixed-height rows |
47
+ | `initialNumToRender` | Keep it to what a screen actually shows; the default renders more |
48
+ | `maxToRenderPerBatch` / `updateCellsBatchingPeriod` | Trade blank space during fast scrolls against JS work per batch |
49
+ | `windowSize` | How many screens are kept mounted around the viewport |
50
+ | `removeClippedSubviews` | Detaches offscreen native views; helps on Android, test it — it has edge cases with absolute positioning |
51
+
52
+ Memoize the row component (`React.memo`) and make `renderItem` stable. If the list is long and rows are heavy, `@shopify/flash-list` recycles native views instead of mounting new ones and is usually a larger win than tuning the props above.
53
+
54
+ ## Images
55
+
56
+ - Size the remote image on the server or CDN; downloading a 3000 px JPEG for a 120 px avatar costs bandwidth, decode time and memory on every row.
57
+ - `expo-image` (or `react-native-fast-image` on bare projects) adds disk/memory caching and progressive loading that the core `Image` does not.
58
+ - Set explicit `width`/`height` so layout does not reflow when the image resolves.
59
+
60
+ ## Startup
61
+
62
+ - **Enable Hermes** (the default in current React Native). Hermes precompiles to bytecode, which removes JS parse time at launch.
63
+ - **Enable the New Architecture** where the project's dependencies support it: Fabric and TurboModules remove the asynchronous bridge serialization, and TurboModules load lazily instead of all at startup.
64
+ - **Audit top-level requires.** Every module imported at the entry point is evaluated at launch. Lazy-import screens through the navigator's lazy options, and defer SDK initialization until after the first screen.
65
+ - **Native side still counts.** Time to first JS frame includes the Android `Application.onCreate` and the iOS `didFinishLaunching` path; see the native references.
66
+
67
+ ## Bundle and app size
68
+
69
+ - Inspect the release source map with a source-map explorer to find the heavy dependency. Moment/lodash-style full imports, duplicated polyfills and an icon font shipped whole are the usual entries.
70
+ - On Android, enable Proguard/R8 for the release variant and ship an App Bundle. On iOS, check the App Store Connect size report per device variant.
71
+ - Expo: `expo-updates` OTA payloads are downloaded by every user — size matters there too, and an update that grows the bundle grows every launch that has to apply it.
72
+
73
+ ## Regression detection in CI
74
+
75
+ - Wrap a scripted journey in Detox or Maestro on a pinned device/emulator, capture the platform's frame data (see [android.md](android.md) and [ios.md](ios.md)), and compare percentiles against a baseline.
76
+ - Assert bundle size in CI: build the release bundle, read its byte size, fail past a percentage delta.
77
+ - `react-native-performance` exposes native startup marks (`nativeLaunchStart`, `runJsBundleStart/End`) as `PerformanceEntry`s, which gives a startup number you can assert on and also report from production.
78
+
79
+ ## Official documentation
80
+
81
+ - React Native performance overview: https://reactnative.dev/docs/performance
82
+ - Optimizing FlatList configuration: https://reactnative.dev/docs/optimizing-flatlist-configuration
83
+ - Profiling with Hermes and React Native DevTools: https://reactnative.dev/docs/react-native-devtools
84
+ - The New Architecture: https://reactnative.dev/architecture/landing-page
85
+ - Expo performance guidance: https://docs.expo.dev/guides/analyzing-bundles/
@@ -19,10 +19,11 @@ One release process for every mobile stack. This file holds the process and what
19
19
  | Native iOS, or the iOS build of any app | [references/ios.md](references/ios.md) |
20
20
  | React Native or Expo app | [references/react-native.md](references/react-native.md) |
21
21
  | Kotlin Multiplatform shared module and both host apps | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
22
+ | In-app purchases and subscription lifecycle | [references/billing.md](references/billing.md) |
22
23
  | Release notes and store listing text | [references/store-copy.md](references/store-copy.md) |
23
24
  | A CI/CD pipeline that builds, signs and uploads | [references/ci-cd.md](references/ci-cd.md) |
24
25
 
25
- Flutter and React Native apps also need the Android and iOS references for signing and store details. Deeper platform guides live in the platform skills: Google Play policy and Play Billing in `mobile-android`, TestFlight and App Store Connect in `mobile-ios`, EAS in `mobile-rn`. Security hardening before release is in `mobile-security`.
26
+ Flutter and React Native apps also need the Android and iOS references for signing and store details. Deeper platform guides live in the platform skills: Google Play policy and Play Billing in `mobile-android`, TestFlight and App Store Connect in `mobile-ios`, EAS in `mobile-rn`. Security hardening before release is in `mobile-security`, and production crash, error and release-health signals are in [mobile-observability](../mobile-observability/SKILL.md).
26
27
 
27
28
  ## 2. Readiness checklist
28
29
 
@@ -0,0 +1,92 @@
1
+ # In-app purchases and subscription lifecycle
2
+
3
+ Applies to every stack. Billing bugs are expensive in a way other mobile bugs are not: a user who paid and did not receive the entitlement files a store complaint, a chargeback, and a one-star review, and the store holds *you* responsible for the reconciliation. The design that avoids this is the same on both platforms.
4
+
5
+ ## The rule everything follows from
6
+
7
+ **The client never decides entitlement.** The app's job is to start a purchase and to hand the resulting receipt/token to your backend. The backend verifies it with the store's server, records the entitlement, and answers "what does this user own?". Every screen reads that answer.
8
+
9
+ An app that grants a feature because a local purchase callback returned success can be defeated with a patched binary, and — more commonly — simply gets it wrong: the callback is missed because the app was killed mid-purchase, or the subscription was cancelled from the store's settings and the app never hears about it.
10
+
11
+ ```
12
+ app ──buy──▶ store ──token/receipt──▶ app ──▶ your backend ──verify──▶ store server
13
+ │
14
+ app ◀────── entitlement state ─────────────────────┘
15
+ ▲
16
+ store server ──server notifications──┘ (renewals, cancellations, refunds, billing retries)
17
+ ```
18
+
19
+ ## Server notifications are not optional
20
+
21
+ The purchase callback covers the moment of purchase. Everything afterwards — a renewal at 3am, a cancellation, a refund, a failed payment entering a retry window, an upgrade, a family-sharing change — happens with the app closed and reaches you only through the store's server-to-server notifications:
22
+
23
+ - **Google Play:** Real-time developer notifications (Pub/Sub), plus the Google Play Developer API for the authoritative subscription state.
24
+ - **Apple:** App Store Server Notifications V2, plus the App Store Server API and signed transactions.
25
+
26
+ Without them, a cancelled subscription keeps working until the app next happens to check, and a refunded purchase is never revoked. "We poll on app launch" is not equivalent: the user who cancelled may not open the app for weeks, and a refunded purchase must be revoked immediately.
27
+
28
+ ## The states to model
29
+
30
+ A subscription is not a boolean. At minimum, entitlement must distinguish:
31
+
32
+ | State | Behaviour |
33
+ |---|---|
34
+ | Active | Full access |
35
+ | Active, cancelled (will not renew) | Full access until the period ends; a good place to ask why |
36
+ | In a billing retry / grace period | **Keep access** and prompt the user to fix payment — revoking here loses users who would have paid |
37
+ | On hold / suspended | Access revoked, recoverable if payment succeeds |
38
+ | Paused (Android) | Access revoked, scheduled to resume |
39
+ | Expired | No access |
40
+ | Refunded / revoked | Access removed immediately, on the notification |
41
+ | Trial / introductory offer | Active, but the eligibility rules differ and are per-account, not per-device |
42
+
43
+ Treating anything other than "active" as "no access" is the standard way to break grace periods and lose revenue.
44
+
45
+ ## Purchase acknowledgement
46
+
47
+ Both stores require the purchase to be consumed or acknowledged, and both **refund automatically** if you do not.
48
+
49
+ - **Google Play:** a purchase not acknowledged (or consumed, for consumables) within three days is automatically refunded and revoked. Acknowledge only **after** your backend has recorded the entitlement, so a failure to record cannot produce a user who paid and owns nothing.
50
+ - **Apple:** a transaction that is not finished is re-delivered on every launch. Finish it only after the entitlement is recorded — the re-delivery is the recovery mechanism, and finishing early discards it.
51
+
52
+ The crash-in-the-middle case is the one to test: purchase succeeds, the app is killed before the backend call. On the next launch, the pending purchase must still be visible, sent, recorded and only then acknowledged.
53
+
54
+ ## Restore and multi-device
55
+
56
+ - A **restore** path is mandatory on iOS (App Review rejects apps without one for non-consumables and subscriptions) and expected everywhere. It must be reachable without being logged in to *your* account if the purchase is tied to the store account.
57
+ - Entitlement follows the **store account**, but your product follows **your** user account. Decide explicitly what happens when they disagree: one store account buying for two of your users, or one of your users signing in on a device with a different store account. This is where fraud and support tickets concentrate; the backend must own the mapping, and it must be enforced there.
58
+ - Prices, currencies and availability come from the store per region. Never hard-code a price string; read it from the product query and display the localized formatted price the store returns.
59
+
60
+ ## Testing
61
+
62
+ - **Google Play:** licence testers with test payment methods, internal testing track, and shortened subscription periods for renewal testing. Purchases must come from a build uploaded to a track with the same package name and signing key.
63
+ - **Apple:** StoreKit Testing in Xcode (a local `.storekit` configuration file, with time acceleration and error injection — the only practical way to test renewals, billing retry and refunds) and Sandbox accounts for the end-to-end path with real server notifications.
64
+ - Test on the backend, not only in the app: replay a server notification payload and assert the entitlement change. That is the code that actually decides access.
65
+ - Cases worth a test each: purchase interrupted by process death; a refund notification; a cancellation followed by a resubscribe; a user who already owns the product buying again; an expired subscription that renews after a grace period.
66
+
67
+ ## Per-stack notes
68
+
69
+ | Stack | Client library |
70
+ |---|---|
71
+ | Native Android | Google Play Billing Library — the deep migration and version material is in [`mobile-android/references/play.md`](../../mobile-android/references/play.md) |
72
+ | Native iOS | StoreKit 2 (`Product`, `Transaction`, `Transaction.updates`, `Transaction.currentEntitlements`) — the `Transaction.updates` listener must be started at launch, or transactions completed outside the app are missed |
73
+ | Flutter | `in_app_purchase` (federated, both stores) or a vendor SDK; the purchase stream must be subscribed before the first purchase and on every launch |
74
+ | React Native / Expo | `react-native-iap`, `expo-in-app-purchases` where available, or a vendor SDK; Expo managed projects need a development build — in-app purchases do not work in Expo Go |
75
+ | Kotlin Multiplatform | Billing is host-specific. Keep the entitlement *model* and the backend calls in `commonMain`, and the store client in each host |
76
+
77
+ A subscription infrastructure vendor (RevenueCat, Adapty and similar) exists precisely because the server-side half above is substantial. Using one is a legitimate decision; it does not remove the need to decide entitlement on a server rather than in the app — it moves that server.
78
+
79
+ ## Store policy
80
+
81
+ Both stores require that digital content consumed in the app is sold through their billing system, with a narrow and changing set of exceptions that vary by region and by court ruling. Alternative payment links have specific, enforced requirements. Before designing anything that bypasses store billing, read the current policy for the target markets — this is the most volatile area of store policy and the most expensive one to get wrong, since the penalty is removal.
82
+
83
+ Release-time checks for the store submission itself are in [readiness.md](readiness.md).
84
+
85
+ ## Official documentation
86
+
87
+ - Google Play Billing: https://developer.android.com/google/play/billing
88
+ - Real-time developer notifications: https://developer.android.com/google/play/billing/getting-ready#configure-rtdn
89
+ - Google Play Developer API, subscriptions: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2
90
+ - StoreKit: https://developer.apple.com/documentation/storekit
91
+ - App Store Server Notifications V2: https://developer.apple.com/documentation/appstoreservernotifications
92
+ - Testing in-app purchases with StoreKit Test: https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode
@@ -18,6 +18,7 @@ Organized by the OWASP MASVS categories. Apply the general checks below, then th
18
18
  | Native iOS (Swift/Objective-C), or `ios/` in any app | [references/ios.md](references/ios.md) |
19
19
  | React Native or Expo | [references/react-native.md](references/react-native.md) |
20
20
  | Kotlin Multiplatform shared code and platform implementations | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
21
+ | OAuth 2.0 / OIDC login, PKCE, tokens and biometric gating, on any stack | [references/auth.md](references/auth.md) |
21
22
 
22
23
  Firebase-specific protection (security rules, App Check) is covered by `mobile-firebase`.
23
24
 
@@ -44,6 +45,8 @@ Every finding needs evidence in the code or configuration at `file:line`. Severi
44
45
  - [ ] Sessions without expiration, or refresh tokens never invalidated on logout.
45
46
  - [ ] Biometrics used only as a local boolean, without unlocking a key in the Keychain or Keystore.
46
47
 
48
+ For anything involving a login flow — OAuth 2.0, OpenID Connect, PKCE, redirect URIs, token rotation or biometric-gated keys — read [references/auth.md](references/auth.md) before reviewing the stack reference.
49
+
47
50
  **MASVS-NETWORK**
48
51
  - [ ] `http://` URLs for APIs.
49
52
  - [ ] Certificate validation disabled or overridden to accept everything.
@@ -0,0 +1,92 @@
1
+ # OAuth 2.0, OIDC and PKCE in mobile apps
2
+
3
+ Applies to every stack. A mobile app is a **public client**: it cannot keep a secret, because anything shipped in the binary can be extracted. Every rule here follows from that one fact, and the authoritative source is **RFC 8252, "OAuth 2.0 for Native Apps"**, which the major identity providers implement and enforce.
4
+
5
+ ## The three rules that decide whether the flow is correct
6
+
7
+ 1. **Authorization Code flow with PKCE. Nothing else.** The Implicit flow and the Resource Owner Password Credentials flow are both disallowed for native apps — implicit returns tokens in a redirect where they leak, and ROPC makes the app handle the password, defeats federated login and blocks MFA. If the code sends a username and password to a `/token` endpoint, that is the finding.
8
+ 2. **No client secret in the app.** A "confidential" client registration used by a mobile binary is a published secret. Register the app as a public client; PKCE is what replaces the secret.
9
+ 3. **The authorization request runs in the system browser, not in a WebView.** RFC 8252 requires an external user-agent — `ASWebAuthenticationSession` on iOS, Custom Tabs on Android — because it shares the system's session (so SSO works), shows the real URL and certificate to the user (so phishing is detectable), and keeps the app out of the credential entry. An embedded `WebView` on the login page gives the app access to the user's typed password and is rejected by most identity providers.
10
+
11
+ ## PKCE, concretely
12
+
13
+ PKCE (RFC 7636) stops an attacker who intercepts the redirect — a malicious app that registered the same custom scheme, or a log that captured the URL — from exchanging the authorization code.
14
+
15
+ ```
16
+ verifier = 43–128 chars from [A-Z a-z 0-9 - . _ ~], from a cryptographic RNG
17
+ challenge = BASE64URL( SHA256( verifier ) ) ← method "S256"
18
+
19
+ /authorize?...&code_challenge=<challenge>&code_challenge_method=S256
20
+ /token ...&code=<code>&code_verifier=<verifier>
21
+ ```
22
+
23
+ What to check in review:
24
+
25
+ - [ ] `code_challenge_method` is `S256`. The `plain` method provides no protection and exists only for constrained clients.
26
+ - [ ] The verifier comes from a **cryptographic** random source — `SecureRandom`, `SecRandomCopyBytes`, `Random.secure()`, `crypto.getRandomValues` — never `Math.random()`, a timestamp or a UUID v4 from a non-crypto source.
27
+ - [ ] A fresh verifier per authorization request, held only in memory, discarded after the exchange.
28
+ - [ ] The `state` parameter is present, random, and **verified** on the callback. `state` is CSRF protection and is separate from PKCE; PKCE does not replace it.
29
+ - [ ] With OIDC, the `nonce` is sent and checked against the `nonce` claim in the ID token.
30
+
31
+ ## Redirects
32
+
33
+ The redirect URI is how the code gets back to the app, and it is the weak point.
34
+
35
+ - **Prefer verified app links**: `https` App Links on Android (with `assetlinks.json` on the domain) and Universal Links on iOS (with `apple-app-site-association`). These are the only redirect types the OS ties to a domain you own; another app cannot claim them.
36
+ - **Custom schemes are claimable** by any installed app. If one is used, it must be app-specific and unguessable-ish (the reverse-DNS form providers recommend, e.g. `com.example.app:/oauth2redirect`), and PKCE must be present — with PKCE, a stolen code is useless.
37
+ - **Loopback redirects** (`http://127.0.0.1:<port>`) are the desktop pattern and are not appropriate on mobile.
38
+ - Validate the callback: correct `state`, expected issuer, and no code reuse.
39
+
40
+ ## Tokens
41
+
42
+ - **Storage:** the Keychain on iOS and Keystore-backed storage on Android — never `UserDefaults`, `SharedPreferences`, `AsyncStorage`, a plain file, `localStorage` in a WebView, or the JavaScript bundle. Per-stack APIs are in the platform references of this skill.
43
+ - **Access tokens are short-lived** (minutes to an hour). A long-lived access token is a finding by itself.
44
+ - **Refresh tokens must rotate.** Each refresh returns a new refresh token and invalidates the old one; a reused old token means theft, and the server must revoke the whole family. Rotation with reuse detection is the mobile standard because a public client cannot prove its identity any other way.
45
+ - **Never put a token in a URL**, a log line, an analytics event, a crash-report attribute or a breadcrumb — see [mobile-observability](../../mobile-observability/SKILL.md).
46
+ - **Validate the ID token** if the client uses its claims: signature against the provider's JWKS, `iss`, `aud`, `exp`, and `nonce`. An ID token is proof of authentication for the client; it is **not** an access token and must never be sent to your API as one.
47
+ - **Authorization is the backend's decision.** Claims decoded on the device decide what to render, never what is allowed. A client that reads `role: admin` from a token and unlocks a feature is a client-side authorization finding regardless of how well the token is stored.
48
+ - **Logout** must revoke the refresh token at the provider (`revocation_endpoint`), clear the token store, and end the browser session where the provider supports RP-initiated logout. Deleting the local copy alone leaves a valid session on the server.
49
+
50
+ ## Biometrics do not authenticate to your server
51
+
52
+ A biometric prompt that returns `true` and unlocks a screen proves nothing to the backend and can be bypassed on a compromised device. Biometrics are correct when they **gate access to a key**: the token or a key that decrypts it is stored with biometric-backed access control (`kSecAccessControlBiometryCurrentSet` on iOS, `setUserAuthenticationRequired` on an Android Keystore key), so failing the prompt makes the data undecryptable rather than just hiding a screen.
53
+
54
+ Invalidate on enrolment change: adding a fingerprint or face must invalidate the key, or a coerced enrolment grants access.
55
+
56
+ ## Use the platform libraries
57
+
58
+ Hand-written OAuth is where these mistakes accumulate. Prefer a maintained implementation that already does RFC 8252 correctly, and keep the verifier, `state` and browser handling out of your own code:
59
+
60
+ | Stack | Typical choice |
61
+ |---|---|
62
+ | Android | AppAuth for Android, or the identity provider's own SDK; Custom Tabs for the browser |
63
+ | iOS | AppAuth for iOS, or the provider's SDK; `ASWebAuthenticationSession` for the browser |
64
+ | Flutter | `flutter_appauth`, or the provider's Flutter SDK; secure storage for tokens |
65
+ | React Native / Expo | `react-native-app-auth`, or `expo-auth-session` (which implements PKCE); never a `WebView` |
66
+ | Kotlin Multiplatform | The host apps own the browser flow; keep only token *use* and refresh scheduling in shared code |
67
+
68
+ Check what the chosen library defaults to. Some have an option to use an embedded WebView or to disable PKCE for legacy providers — both turn a compliant flow into a non-compliant one silently.
69
+
70
+ ## Review checklist
71
+
72
+ - [ ] Authorization Code + PKCE (`S256`); no implicit, no password grant.
73
+ - [ ] No client secret in the app, the repository, or the CI configuration that builds it.
74
+ - [ ] System browser (`ASWebAuthenticationSession` / Custom Tabs), not a `WebView`.
75
+ - [ ] Cryptographic RNG for the verifier and `state`; `state` and `nonce` verified.
76
+ - [ ] Redirect URI is an App Link / Universal Link, or an app-specific scheme with PKCE.
77
+ - [ ] Tokens in Keychain/Keystore-backed storage, short-lived access token, rotating refresh token with reuse detection.
78
+ - [ ] No token in logs, URLs, analytics or crash reports.
79
+ - [ ] ID token validated if used; never sent as an access token.
80
+ - [ ] Authorization enforced by the backend on every request.
81
+ - [ ] Logout revokes server-side.
82
+ - [ ] Biometrics gate a key, not a boolean; enrolment changes invalidate it.
83
+
84
+ ## Official documentation
85
+
86
+ - RFC 8252, OAuth 2.0 for Native Apps: https://datatracker.ietf.org/doc/html/rfc8252
87
+ - RFC 7636, Proof Key for Code Exchange: https://datatracker.ietf.org/doc/html/rfc7636
88
+ - OAuth 2.0 Security Best Current Practice: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
89
+ - OpenID Connect Core: https://openid.net/specs/openid-connect-core-1_0.html
90
+ - OWASP MASVS, authentication and session management: https://mas.owasp.org/MASVS/05-MASVS-AUTH/
91
+ - ASWebAuthenticationSession: https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession
92
+ - Android Custom Tabs: https://developer.chrome.com/docs/android/custom-tabs