Skip to content

React Native

react-native-keychain으로 양 플랫폼 토큰 저장, react-native-app-auth로 ASWebAuthenticationSession / Custom Tabs 통합 OAuth + PKCE.

의존성

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

최소 버전 요구

  • React Native 0.73+
  • iOS 13+, Android API 23+
  • iOS는 Info.plistNSFaceIDUsageDescription 추가 필수 (biometric 사용 시)

⚠️ SoLoader / Hermes 버전 충돌 주의

react-native-keychain 10.x는 SoLoader 버전에 민감합니다. 앱 시작 시 크래시가 발생하면 android/app/build.gradle에 SoLoader 버전을 명시적으로 강제하세요.

OAuth + PKCE 플로우

react-native-app-auth는 PKCE를 자동 처리합니다:

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

const config: AuthConfiguration = {
  issuer: 'https://logi.example.com',
  clientId: 'logi_...',
  redirectUrl: 'com.example.myapp://callback',
  scopes: ['profile', 'email'],
  // PKCE는 기본 활성화. usePKCE: true (default)
  serviceConfiguration: {
    authorizationEndpoint: 'https://logi.example.com/oauth/authorize',
    tokenEndpoint: 'https://logi.example.com/oauth/token',
    revocationEndpoint: 'https://logi.example.com/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;
}

Refresh Token 저장 (react-native-keychain)

옵션을 명시하지 않으면 iOS는 iCloud sync, Android는 KeyStore가 아닌 일반 저장소를 사용할 수 있습니다. 항상 모든 옵션을 명시하세요.

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) {
    // 사이즈 검증: react-native-keychain은 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,
      // ⚠️ iCloud sync 차단
      accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
      // Android: AES + Keystore 백킹
      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 옵션 정리

옵션잠금 상태 접근iCloud sync시나리오
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY권장 기본값 (background refresh OK)
WHEN_UNLOCKED_THIS_DEVICE_ONLY민감도 최상
AFTER_FIRST_UNLOCK⚠️ oniCloud sync 의도된 경우
WHEN_UNLOCKED⚠️ on❌ 비권장

Android backup 제외

React Native Android 프로젝트도 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" ...>

민감 작업에 biometric 추가

accessControl: BIOMETRY_CURRENT_SET을 옵션에 추가하면 토큰 자체가 biometric으로 보호됩니다. 단 background refresh와 양립 불가하므로, 별도 service에 저장하는 high-assurance 토큰에만 적용하세요:

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: 등록된 biometric 변경 시 키 무효화
  authenticationPrompt: {
    title: '민감 작업 인증',
    subtitle: 'Face ID / 지문 인증이 필요합니다',
    cancel: '취소',
  },
});

// 조회 시 자동으로 biometric prompt 표시
const creds = await Keychain.getGenericPassword({ service: HIGH_ASSURANCE_SERVICE });

⚠️ Background refresh와 biometric은 양립 불가

일반 refresh token에는 accessControl을 적용하지 마세요. silent refresh가 막힙니다.

Device Bootstrap (device_secret)

logi의 device bootstrap은 dual mode입니다:

  1. 첫 호출 (bootstrap): POST /api/v1/devices with {device_uuid, platform} → 응답에 device_secret 1회 노출 + PAK 발급
  2. 이후 호출 (refresh): 같은 endpoint에 {device_uuid, platform, device_secret} → 서버가 digest 검증 후 새 PAK 발급. 누락/불일치 시 401.

device_secret을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다.

typescript
// 첫 호출 응답 처리
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);
}

// 이후 PAK 갱신 시
const secret = await loadDeviceSecret();  // null이면 OAuth 재로그인 유도
await fetch(`${BASE}/api/v1/devices`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ device_uuid: uuid, platform: 'ios', device_secret: secret }),
});

별도 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;
}

AsyncStorage에 절대 저장 금지 — 평문 sqlite/file 저장입니다.

점검 체크리스트

  • [ ] accessible 옵션을 ..._THIS_DEVICE_ONLY 변형으로 명시
  • [ ] storage: AES_GCM으로 Android Keystore 백킹
  • [ ] payload 사이즈 60KB 미만 검증
  • [ ] backup_rules.xmlRN_KEYCHAIN.xml 제외
  • [ ] iOS Info.plistNSFaceIDUsageDescription 추가 (biometric 사용 시)
  • [ ] SoLoader / Hermes 버전 정합성
  • [ ] device_secret, refresh token, high-assurance token 별도 service
  • [ ] biometric은 high-assurance service에만 (background refresh 양립 불가)

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