Skip to content

React Native

⚠️ Watch for Universal Link / App Links conflicts with the OAuth callback domain

If the RP app claims a universal/app link on the same host as the IdP, an unrelated deep link arriving mid-sign-in is forced into the OAuth parser and produces a spurious missingCode error (the ainote incident on 2026-05-15). Receive the callback via a custom scheme (<app>://oauth/1pass/callback) wherever possible.

Diagnostics: https://api.1pass.dev/diagnose

Custom scheme pitfall

iOS rejects schemes that contain an underscore (_). my_app:// ❌ → com.example.myapp:// or myapp://

Use react-native-keychain for token storage on both platforms and react-native-app-auth for ASWebAuthenticationSession / Custom Tabs unified OAuth + PKCE.

Expo Web / Hybrid targets

On browser pages in an Expo Web or react-native-web environment, you can use the npm package @logi-auth/browser directly (npm install @logi-auth/browser). A separate SDK exists for native iOS only (the LogiAuth Swift Package; see the iOS guide), and React Native requires a native bridge — there is no automatic integration package yet (TODO).

Dependencies

json
{
  "dependencies": {
    "react-native-keychain": "^10.0.0",
    "react-native-app-auth": "^8.0.3"
  }
}

Minimum version requirements

  • React Native 0.73+
  • iOS 13+, Android API 23+
  • iOS requires NSFaceIDUsageDescription in Info.plist (when using biometric)

⚠️ Watch for SoLoader / Hermes version conflicts

react-native-keychain 10.x is sensitive to the SoLoader version. If the app crashes at startup, explicitly pin the SoLoader version in android/app/build.gradle.

OAuth + PKCE flow

react-native-app-auth handles PKCE automatically:

typescript
import { authorize, refresh, AuthConfiguration } from 'react-native-app-auth';

const config: AuthConfiguration = {
  issuer: 'https://api.1pass.dev',
  clientId: 'logi_...',
  redirectUrl: 'com.example.myapp://callback',
  scopes: ['openid', 'profile:basic', 'email'],
  // PKCE is enabled by default. usePKCE: true (default)
  serviceConfiguration: {
    authorizationEndpoint: 'https://api.1pass.dev/oauth/authorize',
    tokenEndpoint: 'https://api.1pass.dev/oauth/token',
    revocationEndpoint: 'https://api.1pass.dev/oauth/revoke',
  },
};

export async function signIn() {
  const result = await authorize(config);
  // result.accessToken, result.refreshToken, result.accessTokenExpirationDate
  await TokenStorage.save(result.accessToken, result.refreshToken);
  return result;
}

Storing the Refresh Token (react-native-keychain)

If you do not specify the options, iOS may sync to iCloud and Android may use plain storage instead of the KeyStore. Always specify every option.

typescript
import * as Keychain from 'react-native-keychain';

const TOKEN_SERVICE = 'logi.tokens';
const DEVICE_SECRET_SERVICE = 'logi.device_secret';

export const TokenStorage = {
  async save(accessToken: string, refreshToken: string) {
    // Size check: react-native-keychain can corrupt data above 65KB
    const payload = JSON.stringify({ accessToken, refreshToken });
    if (payload.length > 60_000) {
      throw new Error('Token payload exceeds safe limit (60KB)');
    }

    await Keychain.setGenericPassword('logi', payload, {
      service: TOKEN_SERVICE,
      // ⚠️ blocks iCloud sync
      accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
      // Android: AES + Keystore backed
      storage: Keychain.STORAGE_TYPE.AES_GCM,  // hardware-backed when available
    });
  },

  async load(): Promise<{ accessToken: string; refreshToken: string } | null> {
    const creds = await Keychain.getGenericPassword({ service: TOKEN_SERVICE });
    if (!creds) return null;
    return JSON.parse(creds.password);
  },

  async clear() {
    await Keychain.resetGenericPassword({ service: TOKEN_SERVICE });
  },
};

Accessibility options summary

OptionLocked-state accessiCloud syncScenario
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLYRecommended default (background refresh OK)
WHEN_UNLOCKED_THIS_DEVICE_ONLYHighest sensitivity
AFTER_FIRST_UNLOCK⚠️ onWhen iCloud sync is intended
WHEN_UNLOCKED⚠️ on❌ not recommended

Android backup exclusion

React Native Android projects also need backup rules:

xml
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
  <exclude domain="sharedpref" path="RN_KEYCHAIN.xml" />
</full-backup-content>
xml
<!-- AndroidManifest.xml -->
<application android:fullBackupContent="@xml/backup_rules" ...>

Adding biometric for sensitive operations

Adding accessControl: BIOMETRY_CURRENT_SET to the options makes the token itself biometric-protected. Since this is incompatible with background refresh, apply it only to a high-assurance token stored under a separate service:

typescript
const HIGH_ASSURANCE_SERVICE = 'logi.high_assurance';

await Keychain.setGenericPassword('logi', sensitiveToken, {
  service: HIGH_ASSURANCE_SERVICE,
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
  // BIOMETRY_CURRENT_SET: the key is invalidated when the enrolled biometric changes
  authenticationPrompt: {
    title: 'Authenticate for a sensitive action',
    subtitle: 'Face ID / fingerprint authentication is required',
    cancel: 'Cancel',
  },
});

// The biometric prompt is shown automatically on retrieval
const creds = await Keychain.getGenericPassword({ service: HIGH_ASSURANCE_SERVICE });

⚠️ Background refresh and biometric are incompatible

Do not apply accessControl to a normal refresh token. It blocks silent refresh.

Device Bootstrap (device_secret)

logi's device bootstrap is dual-mode:

  1. First call (bootstrap): POST /api/v1/devices with {device_uuid, platform} → the response reveals device_secret once + issues a PAK
  2. Subsequent calls (refresh): {device_uuid, platform, device_secret} to the same endpoint → the server verifies the digest and issues a new PAK. 401 if missing/mismatched.

If you lose the device_secret, an anonymous user has to re-authenticate via OAuth login.

typescript
// Handling the first-call response
const resp = await fetch(`${BASE}/api/v1/devices`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ device_uuid: uuid, platform: 'ios' }),
});
const data = await resp.json();
if (data.device_secret) {
  await saveDeviceSecret(data.device_secret);
}

// When refreshing the PAK afterward
const secret = await loadDeviceSecret();  // if null, prompt an OAuth re-login
await fetch(`${BASE}/api/v1/devices`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ device_uuid: uuid, platform: 'ios', device_secret: secret }),
});

Split into a separate service:

typescript
export async function saveDeviceSecret(secret: string) {
  await Keychain.setGenericPassword('logi', secret, {
    service: DEVICE_SECRET_SERVICE,
    accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
    storage: Keychain.STORAGE_TYPE.AES_GCM,
  });
}

export async function loadDeviceSecret(): Promise<string | null> {
  const creds = await Keychain.getGenericPassword({ service: DEVICE_SECRET_SERVICE });
  return creds ? creds.password : null;
}

❌ Never store it in AsyncStorage — that is unencrypted SQLite/file storage.

Review checklist

  • [ ] accessible option specified with a ..._THIS_DEVICE_ONLY variant
  • [ ] storage: AES_GCM to use Android Keystore backing
  • [ ] payload size verified under 60KB
  • [ ] RN_KEYCHAIN.xml excluded in backup_rules.xml
  • [ ] NSFaceIDUsageDescription added to iOS Info.plist (when using biometric)
  • [ ] SoLoader / Hermes version consistency
  • [ ] device_secret, refresh token, and high-assurance token in separate services
  • [ ] biometric only on the high-assurance service (incompatible with background refresh)

Identity가 제품의 신뢰를 만듭니다.