A reference Expo app for Curity customers integrating the HAAPI React Native SDK into their own React Native / Expo applications. Clone it, point it at your Curity Identity Server, and use it both as a working harness to explore HAAPI flows interactively and as a coding reference whose patterns you can copy into your own app.
It exercises the full Hypermedia Authentication API surface — every step type, every form-field type, RFC 7807 problems, OAuth token exchange and refresh — using a small, readable architecture intended to be lifted file-by-file.
The UI implements the Curity look and feel — a brand-themed light + dark design (Curity color tokens + Roboto, the official authenticator/brand icon set, branded Home and per-step flow screens), all driven by a small src/theme/ token layer. It stays a thin, readable presentation layer over the architecture, so the integration pattern is still the point — and the theme is yours to swap for your own design system.
The authoritative integration reference is the React Native HAAPI SDK — Quickstart on curity.io. Start there for the SDK contract; use this repo as its runnable companion to see those patterns working end-to-end.
- You run a Curity Identity Server (cloud or self-hosted) and want a quick way to try HAAPI against your own clients and authenticators without writing UI from scratch.
- You're building a React Native app that needs HAAPI and you want a worked example of the SDK contract — accessor lifecycle, discriminated-union response handling, error / retry contract, OAuth token exchange and refresh — rather than reading the SDK reference cold.
- You want to copy patterns, not link to a library. Every file is small, deliberately uncoupled, and meant to be read.
It is not a finished consumer app. Sections of this README flag the swap-outs you'll likely make when customizing for your own product (TLS handling, persistence, theming, navigation).
- A working HAAPI flow you can sign into with a username/password client.
- A step engine (
useReducer+ command layer) that drives the flow without scattering SDK calls across the codebase. - A component registry with one file per HAAPI step type and one per form-field type — copy the ones you need, ignore the rest.
- A runtime-editable Settings screen that exposes every SDK config knob (server URLs, redirect URI, client IDs, secrets, attestation, URL session / HttpURLConnection options, full auth-method matrix) — persisted to AsyncStorage and applied on demand via the Restart control in the Home header.
- Typed error handling using the SDK's
HaapiErrorchannel, including retry-aware UI. - An OAuth-only refresh path (no HAAPI re-init) for token rotation.
.
├── README.md ← you are here
├── AGENTS.md ← architecture, conventions & gotchas (agents + humans)
├── CLAUDE.md ← one-line import of AGENTS.md (for Claude Code)
├── LICENSE ← Apache-2.0
├── start-idsvr.sh ← deploy a local Curity Identity Server in Docker
├── stop-idsvr.sh ← tear the deployment down
├── config/ ← docker-template.xml: the demo's HAAPI client config
└── Demo/ ← the Expo app
├── App.tsx
├── app.json ← wires plugins, scheme, ATS, intent filters
├── package.json
├── metro.config.js
├── plugins/ ← two local Expo config plugins (see below)
└── src/
├── config/ ← Settings types + storage + provider + SDK-config factories
├── navigation/← React Navigation bottom tabs (Home / Tokens / Settings)
├── haapi/ ← step engine: reducer + commands + types + provider
└── components/
├── steps/ ← one component per StepType + registry
├── fields/ ← one component per FormFieldType + registry
├── settings/ ← form primitives + Server / iOS / Android / Auth sections
└── ui/ ← Button / Card / Banner primitives
The HAAPI SDK is @curity/identityserver-haapi-react-native-sdk (repo: github.com/curityio/react-native-idsvr-haapi-sdk) — its source is public but it is proprietary (© Curity AB, declares UNLICENSED; Curity-maintained). Demo/package.json consumes the published 1.0.0 release from the public npm registry:
npm install pulls the prebuilt package like any other dependency. To track the latest unreleased changes (or to co-develop the SDK), switch that line to the public git repo — "github:curityio/react-native-idsvr-haapi-sdk#main" — which builds the Expo module on install via its prepare script.
| Tool | Version | Notes |
|---|---|---|
| Node.js | ≥ 22 | The SDK's engines.node is >=22. Use nvm use 22. |
| npm | ≥ 10 | Bundled with Node 22. |
| Expo SDK / RN | 56 / 0.85.3 | The demo tracks the SDK's stack (Expo 56, RN 0.85.3, React 19.2.3, TypeScript 6.0.3). |
| Xcode | ≥ 26.2 | iOS only. iOS deployment target floor is 16.4 (Expo-56 requirement). |
| CocoaPods | ≥ 1.16 | iOS only. Required for Expo autolinking — even though the HAAPI native SDK is pulled via SwiftPM inside the podspec. |
| Android Studio + JDK 17 | latest | Android only. compileSdk 36, minSdk 26. |
| A Curity Identity Server | with HAAPI enabled | Default: https://localhost:8443. See "Server setup" below — note the Android-emulator caveat. |
# 1. From the repo root, switch to Node 22 and install JS deps
cd Demo
nvm use 22
npm install
# 2. Type-check (sanity)
npm run typecheck
# 3. Generate native shells (ios/ and android/)
npx expo prebuild
# 4. iOS: install pods (also resolves the native HAAPI module via autolinking)
cd ios
pod install --repo-update
cd ..
# 5. Run on a simulator/emulator
npx expo run:ios # ── or ──
npx expo run:androidThe first run compiles the HAAPI native module and is slow (~3–5 min). Subsequent runs are incremental.
Need a Curity server? The app lands on a welcome screen but can't authenticate without one. From the repo root,
./start-idsvr.shspins up a pre-configured Curity Identity Server in Docker — see Server setup.
Re-running
expo prebuildafter editingapp.jsonwill clobber manual changes inios/andandroid/. Configure native settings viaapp.json, not by hand.
The demo needs a Curity Identity Server with HAAPI enabled. You have two options.
./start-idsvr.sh (repo root) deploys the Curity Identity Server in Docker, pre-configured with the exact clients this demo connects to by default — so a fresh clone works with no edits.
Prerequisites: a Docker engine, envsubst (brew install gettext) and jq (brew install jq), and a Curity license.json copied into the repo root (grab a free community license from developer.curity.io).
What the script does, step by step — so you know what you're running:
- Verifies
license.jsonis in the repo root (exits with instructions if not). - Clones Curity's public
curityio/mobile-deploymentsrepo intodeployment/(the shared Docker deployment — first run only). - Overlays this demo's HAAPI config (
config/docker-template.xml: the two demo clients + the mobile-app-association used for passkeys) onto that deployment. - Runs the Dockerized Curity Identity Server with your license.
- Prints the base URL to paste into the app's Settings → Base URL.
Environment variables — set these before running:
| Variable | Required? | What it does | Default |
|---|---|---|---|
IDSVR_HOST_NAME |
Yes — unless USE_NGROK=true |
Host the server advertises as its base-url; must be reachable from your simulator/emulator. A LAN IP works for both platforms. |
none — script exits if unset |
USE_NGROK |
No | true exposes the server via an ngrok tunnel instead of a host/IP — reachable anywhere, both platforms. |
false |
APPLE_TEAM_ID |
No — iOS passkeys only | Your Apple Developer Team ID for the server's iOS app-association (<team>.<bundle>). |
MYTEAMID (placeholder) |
APPLE_BUNDLE_ID |
No — iOS passkeys only | Your app's bundle id for that same association. | io.curity.haapidemo |
# Prereq: copy your Curity license.json into the repo root first.
# Most common — advertise a LAN IP both the iOS Simulator and Android Emulator can reach:
export IDSVR_HOST_NAME="$(ipconfig getifaddr en0)" # e.g. 192.168.1.23
./start-idsvr.sh
# Alternative — expose it over an ngrok tunnel instead (works anywhere, both platforms):
export USE_NGROK='true'
./start-idsvr.sh
# Testing iOS passkeys — also pass a Team ID + bundle id you own (real device required):
export IDSVR_HOST_NAME="$(ipconfig getifaddr en0)"
export APPLE_TEAM_ID='ABCDE12345'
export APPLE_BUNDLE_ID='com.example.haapidemo'
./start-idsvr.sh
# Stop the server when you're done:
./stop-idsvr.shHow these values reach the server config: the script exports the variables, then the deployment runs envsubst over config/docker-template.xml — replacing the $RUNTIME_BASE_URL, $APPLE_TEAM_ID, and $APPLE_BUNDLE_ID placeholders with the environment values — before PATCHing the result onto the server via RESTCONF. The substitution runs in a child process, so the variables must be exported, not just assigned: export them first (as above), or set them inline on the same command. A plain APPLE_TEAM_ID=… on its own line (no export) won't reach envsubst.
# Inline form — the variables are exported only for this one command:
APPLE_TEAM_ID='ABCDE12345' APPLE_BUNDLE_ID='com.example.haapidemo' \
IDSVR_HOST_NAME="$(ipconfig getifaddr en0)" ./start-idsvr.shLeave any of them unset and the script falls back to its defaults (MYTEAMID / io.curity.haapidemo), so the iOS app-association won't verify until you provide your own values.
When it finishes, the script prints the server's base URL. Open the Settings tab, set Base URL to that value, tap Save, then Start Authentication on the Home tab. Sign in with the pre-shipped test user demouser / Password1. Run ./stop-idsvr.sh when you're done.
Testing passkeys (WebAuthn)? Passkeys require the server to be associated with your app's signing identity:
- iOS — set
APPLE_TEAM_ID/APPLE_BUNDLE_ID(above) to a Team ID + bundle you own.- Android — the association uses the SHA-256 of the app's signing certificate in
config/docker-template.xml; the shipped value is the one Curity uses in its public Android HAAPI demo. If your Android build is signed with a different keystore, replace it with your build's fingerprint (cd android && ./gradlew :app:signingReport, then copy the SHA-256).For ordinary username/password sign-in (the default test user), you can ignore all of these.
localhostworks in the iOS Simulator (which shares the host's network namespace) but fails inside the Android Emulator — there,localhostis the emulator itself.You can't fix this just on the client side: HAAPI responses, OAuth redirects, and follow-up
action.href/link.hrefvalues all carry the server'sbase-url. If the server thinks of itself aslocalhost:8443and the client points at10.0.2.2:8443, the first request works but every server-issued link is unreachable from Android.For dev, point the Curity server's
base-urlat something both emulators can reach:
- A LAN IP (e.g.
https://192.168.x.x:8443on your dev machine). Works for both emulators if they're on the same LAN. Simplest if you're not behind picky firewalls.- An
ngrok/cloudflared/ similar tunnel (https://abcd1234.ngrok.app). Works anywhere, terminates TLS at the tunnel, and survives moving between networks.Then set the same URL on the client side via the Settings screen. Both ends agree, server-issued links Just Work.
Point the demo at an existing server by editing the values in the Settings tab (no rebuild). Register two HAAPI clients matching the demo's defaults:
- iOS:
haapi-ios-client-emulator-secret-without-attestation - Android:
haapi-android-client-emulator-secret-without-attestation
Both with client secret foo, redirect URI app://haapi, attestation disabled, and openid / profile scopes (adjust to taste). The same definitions live in config/docker-template.xml if you'd rather import them.
You don't need to edit code — Settings persists overrides to AsyncStorage; tap Save, then Restart flow on the Home tab to re-initialize with the new values. See the Settings screen section below. To change the defaults baked into a fresh install (for teammates cloning the repo), edit Demo/src/config/defaults.ts.
The demo is already wired to bypass TLS trust validation for dev servers with self-signed certificates. Two Expo config plugins handle this:
- iOS:
Demo/plugins/withHaapiUnsafeURLSession.jspatchesAppDelegate.swiftto register anUnsafeURLSessionResolveron the SDK'sNativeRegistry. Combined withURLSessionOption.CustominDemo/src/config/ios.ts, all SDK network calls go through aURLSessionwhose delegate trusts any server. - Android:
Demo/plugins/withHaapiUnsafeHttpUrlConnection.jswritesUnsafeHttpURLConnectionResolver.ktnext toMainApplication.ktand patchesMainApplication.onCreateto register it. Combined withHttpUrlConnectionProviderOption.CustominDemo/src/config/android.ts, all SDK network calls go through anHttpsURLConnectionwith a no-op trust manager.
⚠ Dev only. Both plugins disable certificate validation entirely — never ship a build with them enabled to production. Strip the plugin entries from app.json expo.plugins for production prebuilds, or gate them on a build configuration.
If you'd rather use real certs in dev, install your dev CA in the simulator/emulator trust store (iOS: drag the .crt onto the simulator window; Android: Settings → Security → Encryption & credentials → Install cert) and remove the two unsafe-resolver plugins from app.json.
The Settings screen exposes every SDK-consumable knob at runtime. To open it, tap the Settings tab in the bottom tab bar.
Editable fields
| Section | Fields |
|---|---|
| Server / Common | base URL · authorization / token / revocation endpoint URLs · app redirect URI · isAutoRedirect · min token TTL · scopes (comma-separated) |
| iOS | client ID · configuration name · URLSession option (default / ephemeral / custom) · useAttestation · attestation max retries |
| Android | client ID · KeyStore alias · HttpURLConnection provider (default / custom) · useAttestation |
| iOS · Auth | No-auth / Secret / MTLS / MTLS + key-pinning / JWT-asym / JWT-sym, with per-method sub-fields (PEM/PKCS12 filenames, key-pinning hostnames + base64 SHA-256 hashes, bundle option, 9 asymmetric + 3 symmetric algorithm choices) |
| Android · Auth | Same matrix as iOS, with Android sub-fields (KeyStore options, KeyStore aliases, key-pinning entries) |
Apply semantics
Saving persists the draft to AsyncStorage but does not automatically restart the active HAAPI session. After saving, navigate back to the main screen and tap Restart flow — commands.restart() closes the current accessor and re-initializes with the new values. This decoupling lets you batch multiple edits before paying the cost of a re-init.
Persistence + storage
Settings live in AsyncStorage under the key haapi-demo.appSettings.v1 (the .v1 suffix is a fixed storage namespace, not the schema version). AppSettings.settingsVersion: 2 guards forward-incompatible shape changes — loadSettings() structurally validates the payload, ignores any whose settingsVersion doesn't match (falling back to DEFAULT_SETTINGS in Demo/src/config/defaults.ts), and merges a valid payload over the defaults so additive fields stay populated across upgrades.
⚠ The client secret + any JWT symmetric key live in AsyncStorage plaintext. Acceptable for a teaching demo against a dev server. If you fork this for anything closer to production, swap in expo-secure-store for the secret fields.
Defaults for fresh clones
A teammate cloning this repo will boot with the values in Demo/src/config/defaults.ts until they save settings of their own. Edit that file when the canonical dev server / client config changes.
Self-signed-cert guard rail
If you flip URLSession option (iOS) or HttpURLConnection provider (Android) from custom to default while the base URL still looks like a LAN dev host (localhost, 192.168.*, 10.*, 172.*), the form shows an inline warning — the default network stack will reject the self-signed cert. Either keep the custom option (so the unsafe resolver plugins fire) or trust the cert in the simulator/emulator.
This section walks through the integration pattern the demo implements. If you're building your own HAAPI host app, copy the parts that fit and discard the rest.
The SDK is the package @curity/identityserver-haapi-react-native-sdk (an Expo module), published as 1.0.0 on the public npm registry:
// package.json — preferred: the published release
"@curity/identityserver-haapi-react-native-sdk": "^1.0.0"
// alternative: track the public git repo's main branch (builds on install via the SDK's `prepare`)
// "@curity/identityserver-haapi-react-native-sdk": "github:curityio/react-native-idsvr-haapi-sdk#main"npm install # pulls the prebuilt package (git-dep path instead clones main + runs the SDK's `expo-module build`)It resolves like any normal node_modules package, so no metro.config.js tweaks are needed — the default Expo config works:
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);Only if you co-develop the SDK from a local checkout (
file:../sdk) do you need the duplicate-package guards (watchFolders+ ablockListfor the checkout's nestednode_modules/react*).
The SDK takes three configs: per-platform (iosConfig, androidConfig) and a top-level RNHaapiConfiguration that wraps them.
// src/config/shared.ts
import { createHaapiConfiguration } from '@curity/identityserver-haapi-react-native-sdk';
export const APP_REDIRECT = 'app://haapi'; // must match URL scheme below
export function createAppHaapiConfiguration(iosConfig?, androidConfig?) {
return createHaapiConfiguration({
baseUrl: 'https://your-curity-host',
authorizationEndpointUrl: 'https://your-curity-host/oauth/authorize',
tokenEndpointUrl: 'https://your-curity-host/oauth/token',
revocationEndpointUrl: 'https://your-curity-host/oauth/revoke',
iosConfig, androidConfig,
isAutoRedirect: false,
minTokenTtl: 60,
});
}See Demo/src/config/ for the per-platform builders.
In app.json (Expo):
{
"expo": {
"ios": {
"bundleIdentifier": "your.bundle.id",
"infoPlist": {
"CFBundleURLTypes": [
{ "CFBundleURLName": "your.bundle.id.redirect",
"CFBundleURLSchemes": ["app"] }
]
}
},
"android": {
"package": "your.bundle.id",
"intentFilters": [
{ "action": "VIEW",
"data": [{ "scheme": "app", "host": "haapi" }],
"category": ["BROWSABLE", "DEFAULT"] }
]
}
}
}The host of the redirect URI on Android matters — app://haapi ⇒ host: "haapi". iOS doesn't require a host.
For the build to succeed and the iOS app to launch, app.json expo.plugins needs four entries: the SDK's own SPM-embed plugin (referenced by package name — it adds IdsvrHaapiSdk.framework to the app bundle), the two custom TLS plugins (copy-pasteable JS in Demo/plugins/, referenced by relative path), and the community expo-build-properties.
{
"expo": {
"plugins": [
"@curity/identityserver-haapi-react-native-sdk",
"./plugins/withHaapiUnsafeURLSession",
"./plugins/withHaapiUnsafeHttpUrlConnection",
["expo-build-properties", {
"android": { "minSdkVersion": 26, "compileSdkVersion": 36, "targetSdkVersion": 36 },
"ios": { "deploymentTarget": "16.4" }
}]
]
}
}| Plugin | Purpose | Drop if… |
|---|---|---|
@curity/identityserver-haapi-react-native-sdk |
The SDK's own plugin (listed by package name → its app.plugin.js). Adds the IdsvrHaapiSdk Swift Package to your iOS .xcodeproj so the framework is embedded in the app bundle; without it the app crashes at launch with Library not loaded: @rpath/IdsvrHaapiSdk.framework. |
Never (iOS). Requires an SDK build that ships app.plugin.js. |
withHaapiUnsafeURLSession |
iOS TLS bypass for self-signed dev certs. Patches AppDelegate.swift to register a custom URLSession on the SDK's NativeRegistry. |
You trust your dev cert in the simulator keychain, or you only use prod-grade certs. Strip for production. |
withHaapiUnsafeHttpUrlConnection |
Android TLS bypass for self-signed dev certs. Writes an UnsafeHttpURLConnectionResolver.kt next to MainApplication.kt and registers it on NativeRegistry. |
Same as iOS. Strip for production. |
expo-build-properties |
Sets Android minSdkVersion: 26 / compileSdk: 36 / targetSdk: 36 and iOS deploymentTarget: 16.4 — the Expo-56 / SDK requirements. The bare app.json android.minSdkVersion keys are silently ignored, so this plugin is the only way to override Expo's defaults. |
Never. Required for builds against HAAPI SDK 5.4+. |
The custom plugins use sentinel comments for idempotency, so npx expo prebuild can be re-run safely.
The SDK exposes two factories — initializeForHaapi(config, logger?) returns an accessor with both a haapiManager (flow operations) and an oauthTokenManager (token operations). initializeForOAuth(config, logger?) returns just the token manager for refresh-only paths.
import { initializeForHaapi, getRNLogger, RNLogLevel } from '@curity/identityserver-haapi-react-native-sdk';
const logger = getRNLogger({ logLevel: RNLogLevel.Info, isSensitiveValueMasked: true });
const accessor = await initializeForHaapi(config, logger);
let response = await accessor.haapiManager.start();
// response.responseCategory === 'representation' | 'problem'
// response.representation.stepType === one of 14 valuesThe full call sequence — start → submitForm/followLink → ... → OAuthAuthorizationResponse → fetchAccessToken — is what the demo's HaapiProvider (Demo/src/haapi/HaapiProvider.tsx) wraps in a clean command interface.
Every start() / submitForm() / followLink() returns a HaapiResponse discriminated on responseCategory and (when representation) on stepType. There are 14 step types and you need a component for each — even if some are placeholders.
The demo's pattern:
// src/components/steps/index.ts
export const stepRegistry: StepRegistry = {
[StepType.AuthenticatorSelector]: AuthenticatorSelectorStep,
[StepType.InteractiveForm]: InteractiveFormStep,
// ...one entry per StepType value...
};
// FlowScreen.tsx
const StepComponent = stepRegistry[representation.stepType];
return <StepComponent step={representation} />;StepRegistry is a mapped type that guarantees each component receives the correctly narrowed step variant. No switch, no manual narrowing.
The same trick handles form fields — see Demo/src/components/fields/.
Every SDK failure rejects the promise with a typed HaapiError:
import { isHaapiError, HaapiErrorCode } from '@curity/identityserver-haapi-react-native-sdk';
try {
const response = await accessor.haapiManager.submitForm(action, params);
} catch (e) {
if (isHaapiError(e)) {
if (e.code === HaapiErrorCode.AccessorAlreadyInitialized) { /* close first */ }
if (e.recovery?.kind === 'retryable') { /* show retry UI */ }
}
}Demo/src/haapi/errorFormat.ts (lifted from the SDK example) covers the common formatting and recovery-aware UI bits.
Important: server-side authentication problems (RFC 7807) come back inside HaapiResponse.responseCategory === 'problem' — they are not thrown. Same for OAuth token-endpoint errors (TokenResponse.responseType === 'error').
Only one accessor is held at a time. Switching modes (HAAPI → OAuth-only refresh, or starting a new flow) requires accessor.close() first or you get HAAPI_ACCESSOR_ALREADY_INITIALIZED. FormAction / Link references are bound to the accessor that produced them — submitting a stale one after close throws HAAPI_FLOW_CACHE_MISS.
┌──────────────────────────────────────────────────────────┐
│ SettingsProvider (src/config/SettingsProvider.tsx) │
│ AsyncStorage ↔ { settings, draft, hydrated, │
│ save, reset, discard } │
└──────────────────────┬───────────────────────────────────┘
│ useSettings()
▼
┌──────────────────────────────────────────────────────────┐
│ HaapiProvider (src/haapi/HaapiProvider.tsx) │
│ │
│ ┌─ accessorRef (HaapiAccessor | null) ────────────┐ │
│ ├─ settingsRef (latest persisted settings) ───────┤ │
│ ├─ stateRef (current UiState mirror) ─────────────┤ │
│ └─ dispatch (engine reducer) │ │
│ │
│ buildConfig() reads settingsRef.current → │
│ createAppHaapiConfiguration(server, ios?, android?) │
│ │
│ Commands (async, wrap SDK calls, dispatch events): │
│ • submitForm(action, params) │
│ • followLink(link) │
│ • fetchTokens() │
│ • refresh(refreshToken) │
│ • close() / restart() │
└──────────────────────┬───────────────────────────────────┘
│ useHaapi() → { state, commands }
▼
┌──────────────────────────────────────────────────────────┐
│ RootNavigator (bottom tabs) │
│ ├─ Home tab → FlowScreen │
│ │ ├─ HomeLanding (hero + Start Authentication) │
│ │ │ shown while engine is uninitialized │
│ │ ├─ StatusBar (busy / error / retry banner) │
│ │ ├─ stepRegistry[step.stepType] ← 14 components │
│ │ │ └─ each step renders fields via │
│ │ │ fieldRegistry[field.fieldType] ← 7 │
│ │ ├─ ProblemView (RFC 7807) │
│ │ └─ "Flow complete" tip → Open Tokens │
│ ├─ Tokens tab → TokenScreen │
│ │ ├─ TokenView (on completed state) │
│ │ └─ Token Actions (refresh / revoke) │
│ └─ Settings tab → SettingsScreen │
│ ├─ ServerSection │
│ ├─ IosSection (+ AuthMethodSection) │
│ ├─ AndroidSection (+ AuthMethodSection) │
│ └─ sticky bar [Reset] [Discard] [Save] │
└──────────────────────────────────────────────────────────┘
The reducer (src/haapi/engine.ts) is pure — no SDK imports, no async — so it's trivially unit-testable. The command layer is the only place that calls the SDK. The Settings layer is the only place that mutates SDK config. That separation is the demo's main design point.
- iOS build succeeds, app crashes at launch with
Library not loaded: @rpath/IdsvrHaapiSdk.framework—@curity/identityserver-haapi-react-native-sdkisn't in yourapp.jsonplugins(or your installed SDK predates itsapp.plugin.js). Listing the package applies the SDK's SPM-embed plugin, which adds the framework to the app bundle; then re-runnpx expo prebuild. - Android build fails with
manifest merger failed: uses-sdk:minSdkVersion 24 cannot be smaller than version 26—app.jsonandroid.minSdkVersionis silently ignored. Useexpo-build-propertieswithminSdkVersion: 26. - Android
INSTALL_FAILED_UPDATE_INCOMPATIBLE— the emulator has a previous build with a different keystore. Fix:adb -s <emu> uninstall io.curity.haapidemo, then re-run. - Self-signed HTTPS certs are not fixed by ATS exceptions alone — the unsafe URLSession/HttpURLConnection plugins are required for cert bypass. Or trust the cert in the simulator/emulator trust store (and drop the plugins).
- Metro
metro-config/src/defaults/exclusionListis no longer exported inmetro-config@0.83+— usemetro-config/private/defaults/exclusionListand the ESM.defaultshape. - Swift 6 strict-import — bare
import IdsvrHaapiReactNativeSdkinAppDelegate.swiftfails withambiguous implicit access levelbecause the file already hasinternal import Expo. Useinternal import IdsvrHaapiReactNativeSdk. problemKindis a string literal, not theProblemTypeenum.problem.problemKind === 'invalidInput'✓ —problem.problemKind === ProblemType.InvalidInputProblem✗ (that enum holds problem URIs, not discriminator values).initializeForOAuthno-attestation rejection — fixed in SDK 5.5.0. Previously (≤5.4) it failed withinvalidConfiguration("Unsupported Configuration … useAttestation is set to false")when neither attestation nor DCR was enabled, so standalone OAuth-only token ops needed an open HAAPI accessor'soauthTokenManager. As of 5.5.0initializeForOAuthworks for no-attestation/no-DCR configs; the demo still prefers an active accessor's manager when one exists.- iOS attestation requires a real device. Apple App Attest (
DCAppAttestService) is unavailable on the iOS Simulator — so any flow with attestation enabled (useAttestation: true) must be tested on a physical iOS device. The demo shipsuseAttestation: falseand the bundled server config (config/docker-template.xml) allows sign-in without attestation, so the default flow runs fine on the Simulator; theclient-attestationpolicies there are pre-wired for future attestation testing. - A red
use_dpop_nonceLogBox error in development is benign (iOS and Android as of SDK 5.5.0). The SDK gets ause_dpop_noncechallenge, retries with the server's new nonce, and the flow completes; the SDK just logs that recoverable retry at error level. iOS logsDPoP has been rejected … Retrying …; Android 5.5.0 (which now forwards native logs to JS) logsFailure obtaining HAAPI Access Token information … use_dpop_nonce.Demo/index.tssilences these withLogBox.ignoreLogs(anchored touse_dpop_nonce, so real failures still surface; dev-only, temporary — remove once the SDK adjusts the log level). Doesn't affect release builds. - Fast Refresh on the provider can leak the accessor and trigger
HAAPI_ACCESSOR_ALREADY_INITIALIZEDon re-mount. Reload Metro if you hit it. - Re-running
expo prebuildclobbers manual edits toios/andandroid/. Editapp.json(or a plugin) instead. The four built-in plugins are idempotent on sentinel markers, so re-running prebuild reapplies them. - Don't upgrade React Native / Expo independently of the SDK — version mismatches cause silent ABI breaks at native build time.
localhostas the Curitybase-urldoesn't work cross-platform. iOS Simulator can reach the host'slocalhost; Android Emulator can't, and changing only the client URL doesn't help because the server issues follow-up links anchored on its ownbase-url. Configure the server with a LAN IP or an ngrok-style tunnel and use the same URL on the client (Settings → Base URL).- Saved settings outlive the binary. AsyncStorage survives app reinstalls on the same simulator (but is wiped by
xcrun simctl eraseand Android emulator data wipe). If you changedefaults.tsand want fresh defaults to take effect, either tap Reset in the Settings screen or uninstall + reinstall the app. - Banner crashes on mixed text + JSX children if you build new banner instances. RN treats interpolated children as an array; bare strings outside
<Text>are a fatal runtime error. The shippedBannerdetects primitive children and wraps the whole subtree in<Text>. If you write a custom banner, do the same.
The demo defaults reflect a dev-friendly setup. When you adopt patterns into a real customer app, you'll typically swap or remove the following:
| Concern | Demo behavior | Adjusting for your situation |
|---|---|---|
| TLS validation | withHaapiUnsafeURLSession (iOS) / withHaapiUnsafeHttpUrlConnection (Android) plugins register URLSession / HttpURLConnection factories that trust any server cert, gated on the Custom URLSession / HttpURLConnection options in Settings. This is intentional — emulator + self-signed dev cert is the most common starting point, and it works out of the box. |
Keep as-is for emulator-based dev/testing. When you move to production builds, gate the two unsafe-* plugins behind a build configuration (or strip them) and either trust real certs via the OS or pin them via the SDK's MTLS-key-pinning auth method. The Custom ↔ Default switch in Settings is a runtime toggle for either mode. |
| Client secret in Settings | Plaintext in AsyncStorage. Fine for typing a dev secret. | Swap in expo-secure-store for the secret field (and the JWT symmetric key) if your product needs the Settings screen at all (most don't). |
| Refresh-token storage | The demo doesn't persist tokens — refresh / revoke run from the Tokens tab (TokenScreen), against the tokens held in memory. |
Persist tokens.refreshToken in expo-secure-store and call initializeForOAuth(...) on the next launch. (buildForOAuth() works for no-attestation configs as of SDK 5.5.0, so a standalone refresh path no longer needs DCR/attestation.) |
| Settings UI | Always visible in the demo so you can experiment. | Strip or gate behind a debug flag in your product; bake the resolved config into defaults.ts. |
| Server URLs | Default to https://localhost:8443 (works on iOS Simulator; Android Emulator needs 10.0.2.2 or adb reverse). |
Bake your endpoints into defaults.ts; users override per-machine via Settings. |
| Bundle id / URL scheme | io.curity.haapidemo / app://haapi. |
Pick your own (must match the server-side client redirect registration). |
| Theming | Curity light + dark token layer in src/theme/ (the old ui/styles.ts palette is retired). |
Match your product design system. |
| Navigation | Three-tab bottom nav (Home / Tokens / Settings). | Extend with the flows your app actually has. |
About the app.json plugins — besides the SDK's own SPM-embed plugin (referenced by package name; see the table above), the demo lists withHaapiUnsafeURLSession, withHaapiUnsafeHttpUrlConnection, and expo-build-properties:
expo-build-propertiesis required for any build (the SDK's minSdk / iOS deployment-target pinning) and should stay.- The two
withHaapiUnsafe*plugins are required for the typical emulator + self-signed dev cert workflow and are why the demo runs out of the box. Don't reflexively strip them — you'll just have to rediscover why your app can't complete the TLS handshake to a self-signed dev server. For production builds, gate them via a build config or remove them and switch Settings to theDefaultURLSession / HttpURLConnection.
| Feature | Status |
|---|---|
| BankID / WebAuthn / External Browser end-to-end handling | Placeholder UI only |
Persisted refresh tokens (expo-secure-store for tokens) |
Not implemented |
| Secure storage for the client secret typed in Settings | AsyncStorage plaintext today; swap in expo-secure-store for non-toy use |
| Multiple saved Settings profiles (dev / staging / prod) | Single config slot |
| Import/export Settings as JSON | Not implemented |
| Well-known OIDC metadata auto-fill in Settings | Not implemented |
| Tests | None — reducer is pure and test-ready |
.env-based config |
Settings UI covers per-machine values; .env would let multiple checkouts share defaults |
| CI / Fastlane | Not set up |
| Production-grade theming | Curity light + dark token layer ships in src/theme/; further design-system polish is on you |
| DCR (Dynamic Client Registration) | Unblocks standalone OAuth-only token ops (Tokens tab) when no HAAPI session is active |
| Production-safe TLS handling | Unsafe-cert plugins must be stripped/gated before shipping |
See AGENTS.md for architecture notes and the in-repo deferred list.
- React Native HAAPI SDK — Quickstart & developer guide — the official SDK documentation; start here.
- Curity learn resources — guides for HAAPI and mobile setup.
- Expose a local Curity Identity Server with ngrok — reach the server from both simulators/emulators.
- Configure native passkeys for mobile logins.
- curity.io for everything about the Curity Identity Server.
The demo code is licensed under Apache-2.0 (see LICENSE). The HAAPI React Native SDK it consumes is a separate work: © Curity AB, source-available but proprietary (its source is public; it declares UNLICENSED and is governed by Curity's own license) — see the @curity/identityserver-haapi-react-native-sdk package and its repo at github.com/curityio/react-native-idsvr-haapi-sdk.