PAYATHON 2026

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 文档

具体步骤是:

  1. 在两个应用 target 中启用 Keychain Sharing,配置同一个共享组。
  2. 检查最终签名产物中的 keychain-access-groups entitlement。
  3. Cordova 插件和 React Native 原生模块读写时,使用一致的 kSecAttrAccessGroup、kSecAttrService 和 kSecAttrAccount。
  4. 由有权读取旧凭据的应用将旧凭据迁移到共享组;配置共享组不会自动迁移已有私有条目。

下面是读取示例,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 的某个默认行为。

备注:内容仅供参考。