Android/iOS 上 Cordova 与 React Native 如何安全共享 token?
Android 和 iOS 都可以实现跨应用共享登录状态,但实现方式不同。Android 建议通过受权限保护的原生通信接口访问凭据,iOS 可以使用共享 Keychain access group。目前的信息不足以确定反向读取失败的具体原因。
createPackageContext 不会把调用进程变成另一个应用,也不会自动授予访问对方私有文件或密钥的权限。Native Module 和 Cordova 插件负责桥接原生能力,调用仍受系统权限限制。Android Context 文档
为什么一个方向能读取,另一个方向失败?
两个方向可能使用了不同的存储位置、密钥或读取逻辑。建议先区分“找不到数据”和“找到密文但无法解密”,再按下面的顺序排查。
1. 确认实际存储位置
检查两个插件的原生代码,记录以下信息,但不要记录 token 或密钥内容:
- 实际使用的包名、Context 和进程 UID。
- SharedPreferences 文件名、键名及存储区域。
- React Native 端是否真的写入了 SharedPreferences,还是使用数据库、文件或其他存储。
- 写入是否成功,以及失败发生在读取、反序列化还是解密阶段。
相同的 JavaScript 键名不代表两个插件访问的是同一个原生存储。通过 Cordova 读取 React Native 数据时,也要确认读取逻辑指向 React Native 实际写入的位置。
2. 确认 sharedUserId 是否实际生效
同一开发者发布的应用不一定拥有相同 UID,使用相同签名也不会自动共享私有数据。
对于已有的 sharedUserId 方案,应核对两个已安装应用的实际 UID、签名和最终 Manifest。sharedUserId 自 API level 29 起已弃用,Android 官方建议使用 Service 或 ContentProvider 进行应用间通信。不要把 sharedUserId 作为新方案的基础,也不要直接删除已发布应用中的配置来尝试修复。Android Manifest 文档
3. 单独核对加密协议和密钥
统一为 RSA/ECB/PKCS1Padding 只能解决 transformation 不一致这一类问题,还需要确认:
- 解密端是否能使用加密时对应的私钥。
- 两端使用的 key alias、密文编码和序列化格式是否一致。
- 如果采用混合加密,双方是否使用相同的数据密钥封装方式、IV 和其他参数。
在默认隔离的应用身份下,两个应用使用相同的 Android Keystore alias,不代表访问的是同一把密钥。复制 SharedPreferences 中的密文也不会同时转移 Keystore 密钥访问权。旧 sharedUserId 环境需要按实际安装状态核对,不能直接套用普通应用的隔离假设。Android Keystore 文档
4. 排查跨进程缓存
如果重启应用后才能读到另一个应用刚写入的值,应重点检查缓存与落盘时序。Android 明确说明 SharedPreferences 不支持跨进程使用,因此即使底层文件可访问,也不适合作为两个应用共同读写的 token 同步机制。把 apply() 改成 commit() 不能完整解决跨进程一致性问题。SharedPreferences 文档
Android:让凭据由一个应用统一管理
迁移期间,可以由 App 1 管理现有凭据,并通过受保护的 Service 或 ContentProvider 提供有限操作。React Native Native Module 调用该接口,Cordova 插件调用同一套凭据管理逻辑,避免双方直接操作彼此的存储文件。
如果两个应用使用符合签名权限匹配要求的签名,可以使用 signature 级别的自定义权限限制访问。Android 自定义权限文档
例如,App 1 的 Manifest 可以声明:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="com.example.auth.permission.ACCESS_SESSION"
android:protectionLevel="signature" />
<application>
<service
android:name=".SessionService"
android:exported="true"
android:permission="com.example.auth.permission.ACCESS_SESSION" />
</application>
</manifest>
App 2 声明使用该权限:
<uses-permission
android:name="com.example.auth.permission.ACCESS_SESSION" />
示例中的包名和类名仅供参考。Manifest 展示的是访问边界,还需要实现 SessionService、Binder 接口、显式绑定及异常处理。若只允许指定应用访问,还应在服务端校验调用 UID 对应的包与签名,不要信任请求参数中自报的包名。
接口应围绕实际业务设计,例如获取有效会话、刷新会话和退出登录。条件允许时,让凭据管理方保管 refresh_token 并执行刷新,仅返回调用方确实需要的结果,减少长期凭据的副本。
如果登录发生在 App 2,也应通过同一个受保护接口更新统一管理的会话,避免 App 1、App 2 各自维护一份可能过期的 refresh_token。公共目录不适合充当认证凭据交换区。
iOS:配置共享 Keychain access group
iOS 不使用 Android 的 sharedUserId 或 SharedPreferences 方案。可以为两个应用配置 Keychain Sharing,并确保它们的签名和 provisioning profile 授权同一个 access group。仅仅属于同一开发团队,还不足以自动共享默认 Keychain 数据。Apple Keychain Sharing 文档
具体步骤是:
- 在两个应用 target 中启用 Keychain Sharing,配置同一个共享组。
- 检查最终签名产物中的
keychain-access-groupsentitlement。 - Cordova 插件和 React Native 原生模块读写时,使用一致的
kSecAttrAccessGroup、kSecAttrService和kSecAttrAccount。 - 由有权读取旧凭据的应用将旧凭据迁移到共享组;配置共享组不会自动迁移已有私有条目。
下面是读取示例,accessGroup 应传入最终 entitlement 中的实际完整值:
import Security
import Foundation
func readSharedRefreshToken(
accessGroup: String
) -> (status: OSStatus, data: Data?) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrService as String: "com.example.auth",
kSecAttrAccount as String: "refresh_token",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return (status, result as? Data)
}
写入端必须使用相同的匹配属性,并处理新增、更新及错误状态。若有多个账号,还需要把账号标识纳入条目设计。App Groups 也能提供特定共享能力,但共享 UserDefaults 并不等同于安全存储认证凭据。
token 能读取之后,还要处理哪些问题?
先确认认证服务器是否允许 App 2 使用该 refresh_token。如果两个应用属于不同 OAuth client,不能因为开发者相同就假定 token 可以通用;OAuth refresh token 与其获发客户端存在绑定关系。RFC 6749 第 6 节
如果服务端启用了 refresh token rotation,应串行处理同一会话的刷新,并及时保存新返回的 refresh_token,避免两个应用同时使用旧值。退出登录、切换账号、凭据撤销和刷新失败,也需要有明确的同步与重新登录流程。
排查目前的单向读取问题时,可对照两个方向的原生存储位置、实际 UID、密钥来源和具体错误。现有信息不足以将原因归结为 React Native 或 Cordova 的某个默认行为。
备注:内容仅供参考。
