React Native
react-native-keychain으로 양 플랫폼 토큰 저장, react-native-app-auth로 ASWebAuthenticationSession / Custom Tabs 통합 OAuth + PKCE.
의존성
{
"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.plist에NSFaceIDUsageDescription추가 필수 (biometric 사용 시)
⚠️ SoLoader / Hermes 버전 충돌 주의
react-native-keychain 10.x는 SoLoader 버전에 민감합니다. 앱 시작 시 크래시가 발생하면 android/app/build.gradle에 SoLoader 버전을 명시적으로 강제하세요.
OAuth + PKCE 플로우
react-native-app-auth는 PKCE를 자동 처리합니다:
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가 아닌 일반 저장소를 사용할 수 있습니다. 항상 모든 옵션을 명시하세요.
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 | ✅ | ⚠️ on | iCloud sync 의도된 경우 |
WHEN_UNLOCKED | ❌ | ⚠️ on | ❌ 비권장 |
Android backup 제외
React Native Android 프로젝트도 backup rules가 필요합니다:
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="RN_KEYCHAIN.xml" />
</full-backup-content><!-- AndroidManifest.xml -->
<application android:fullBackupContent="@xml/backup_rules" ...>민감 작업에 biometric 추가
accessControl: BIOMETRY_CURRENT_SET을 옵션에 추가하면 토큰 자체가 biometric으로 보호됩니다. 단 background refresh와 양립 불가하므로, 별도 service에 저장하는 high-assurance 토큰에만 적용하세요:
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입니다:
- 첫 호출 (bootstrap):
POST /api/v1/deviceswith{device_uuid, platform}→ 응답에device_secret1회 노출 + PAK 발급 - 이후 호출 (refresh): 같은 endpoint에
{device_uuid, platform, device_secret}→ 서버가 digest 검증 후 새 PAK 발급. 누락/불일치 시 401.
device_secret을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다.
// 첫 호출 응답 처리
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로 분리:
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.xml에RN_KEYCHAIN.xml제외 - [ ] iOS
Info.plist에NSFaceIDUsageDescription추가 (biometric 사용 시) - [ ] SoLoader / Hermes 버전 정합성
- [ ]
device_secret, refresh token, high-assurance token 별도 service - [ ] biometric은 high-assurance service에만 (background refresh 양립 불가)