@oxyhq/services 19.1.3 → 19.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'so.oxy.identity'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "so.oxy.identity"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.0"
14
+ }
15
+ }
16
+
17
+ dependencies {
18
+ // Hardware-backed EncryptedSharedPreferences (MasterKey AES256_GCM) for the
19
+ // cross-app shared identity keypair.
20
+ implementation "androidx.security:security-crypto:1.1.0-alpha06"
21
+ }
@@ -0,0 +1,10 @@
1
+ <manifest>
2
+ <!--
3
+ Intentionally empty. The signature-protected <permission> and the
4
+ <provider> that hosts the shared identity are declared per-app by the
5
+ Commons config plugin (`withSharedIdentityProvider.js`), NOT here — only
6
+ Commons (the identity vault) should host the provider, and its authority is
7
+ the app's own ${applicationId}.identity. Reader apps add only the matching
8
+ <uses-permission> via `withSharedIdentityReader.js`.
9
+ -->
10
+ </manifest>
@@ -0,0 +1,84 @@
1
+ package so.oxy.identity
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.Bundle
6
+ import expo.modules.kotlin.exception.Exceptions
7
+ import expo.modules.kotlin.modules.Module
8
+ import expo.modules.kotlin.modules.ModuleDefinition
9
+
10
+ /**
11
+ * JS bridge for the cross-app shared Oxy identity.
12
+ *
13
+ * - `putShared` persists the keypair into THIS app's own hardware-backed
14
+ * EncryptedSharedPreferences (only Commons calls this).
15
+ * - `getShared` resolves the keypair from the LOCAL store first (Commons reading
16
+ * itself), then from the Commons ContentProvider — prod authority, then the
17
+ * dev-variant authority. The `signature` permission on the provider means only
18
+ * apps signed with the shared Oxy release key can resolve it.
19
+ * - `hasShared` / `clearShared` are the check + local teardown helpers.
20
+ *
21
+ * Every failure resolves to null / no-op so the JS layer degrades to the app's
22
+ * normal interactive sign-in path.
23
+ */
24
+ class OxyIdentityModule : Module() {
25
+ private val context: Context
26
+ get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
27
+
28
+ override fun definition() = ModuleDefinition {
29
+ Name("OxyIdentity")
30
+
31
+ AsyncFunction("getShared") {
32
+ readShared()
33
+ }
34
+
35
+ AsyncFunction("putShared") { privateKey: String, publicKey: String ->
36
+ OxyIdentityStore.write(context, privateKey, publicKey)
37
+ }
38
+
39
+ AsyncFunction("hasShared") {
40
+ readShared() != null
41
+ }
42
+
43
+ AsyncFunction("clearShared") {
44
+ OxyIdentityStore.clear(context)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Local EncryptedSharedPreferences first, then the Commons provider (prod
50
+ * authority, then dev). Returns null on any failure.
51
+ */
52
+ private fun readShared(): Map<String, String>? {
53
+ runCatching { OxyIdentityStore.read(context) }.getOrNull()?.let { (priv, pub) ->
54
+ return mapOf("privateKey" to priv, "publicKey" to pub)
55
+ }
56
+
57
+ for (authority in PROVIDER_AUTHORITIES) {
58
+ val result = runCatching { callProvider(authority) }.getOrNull()
59
+ if (result != null) return result
60
+ }
61
+ return null
62
+ }
63
+
64
+ private fun callProvider(authority: String): Map<String, String>? {
65
+ val uri = Uri.parse("content://$authority")
66
+ val bundle: Bundle = context.contentResolver.call(uri, METHOD_GET_SHARED, null, null)
67
+ ?: return null
68
+ val priv = bundle.getString(OxyIdentityStore.KEY_PRIVATE) ?: return null
69
+ val pub = bundle.getString(OxyIdentityStore.KEY_PUBLIC) ?: return null
70
+ if (priv.isEmpty() || pub.isEmpty()) return null
71
+ return mapOf("privateKey" to priv, "publicKey" to pub)
72
+ }
73
+
74
+ companion object {
75
+ private const val METHOD_GET_SHARED = "getShared"
76
+
77
+ // Commons hosts the provider at "${applicationId}.identity". Try the prod
78
+ // app id first, then the dev variant ("so.oxy.commons.dev").
79
+ private val PROVIDER_AUTHORITIES = listOf(
80
+ "so.oxy.commons.identity",
81
+ "so.oxy.commons.dev.identity"
82
+ )
83
+ }
84
+ }
@@ -0,0 +1,77 @@
1
+ package so.oxy.identity
2
+
3
+ import android.content.ContentProvider
4
+ import android.content.ContentValues
5
+ import android.content.Context
6
+ import android.content.pm.PackageManager
7
+ import android.database.Cursor
8
+ import android.net.Uri
9
+ import android.os.Bundle
10
+
11
+ /**
12
+ * Cross-process read surface for the shared Oxy identity, hosted ONLY by Commons
13
+ * (declared per-app by the `withSharedIdentityProvider` config plugin with a
14
+ * `signature`-level permission, authority `${applicationId}.identity`).
15
+ *
16
+ * `call("getShared")` returns the keypair from Commons's own
17
+ * EncryptedSharedPreferences — but ONLY after verifying the caller is signed
18
+ * with the SAME certificate. That signature check is belt-and-suspenders on top
19
+ * of the manifest's `signature` permission: even if the permission gate were
20
+ * ever misconfigured, a differently-signed caller still gets null.
21
+ *
22
+ * All standard CRUD operations are no-ops; this provider exists solely for the
23
+ * `call()` channel.
24
+ */
25
+ class OxyIdentityProvider : ContentProvider() {
26
+ override fun onCreate(): Boolean = true
27
+
28
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
29
+ if (method != METHOD_GET_SHARED) return null
30
+ val ctx = context ?: return null
31
+ if (!callerSignatureMatches(ctx)) return null
32
+
33
+ val pair = runCatching { OxyIdentityStore.read(ctx) }.getOrNull() ?: return null
34
+ return Bundle().apply {
35
+ putString(OxyIdentityStore.KEY_PRIVATE, pair.first)
36
+ putString(OxyIdentityStore.KEY_PUBLIC, pair.second)
37
+ }
38
+ }
39
+
40
+ /**
41
+ * True when the calling package shares this app's signing certificate. Uses
42
+ * `checkSignatures` (deprecated but still the simplest correct cross-package
43
+ * signature comparison; returns SIGNATURE_MATCH only for same-cert apps).
44
+ */
45
+ private fun callerSignatureMatches(ctx: Context): Boolean {
46
+ val caller = callingPackage ?: return false
47
+ return runCatching {
48
+ @Suppress("DEPRECATION")
49
+ ctx.packageManager.checkSignatures(caller, ctx.packageName) == PackageManager.SIGNATURE_MATCH
50
+ }.getOrDefault(false)
51
+ }
52
+
53
+ override fun query(
54
+ uri: Uri,
55
+ projection: Array<out String>?,
56
+ selection: String?,
57
+ selectionArgs: Array<out String>?,
58
+ sortOrder: String?
59
+ ): Cursor? = null
60
+
61
+ override fun getType(uri: Uri): String? = null
62
+
63
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
64
+
65
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
66
+
67
+ override fun update(
68
+ uri: Uri,
69
+ values: ContentValues?,
70
+ selection: String?,
71
+ selectionArgs: Array<out String>?
72
+ ): Int = 0
73
+
74
+ companion object {
75
+ private const val METHOD_GET_SHARED = "getShared"
76
+ }
77
+ }
@@ -0,0 +1,76 @@
1
+ package so.oxy.identity
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import androidx.security.crypto.EncryptedSharedPreferences
6
+ import androidx.security.crypto.MasterKey
7
+
8
+ /**
9
+ * Shared accessor for the hardware-backed EncryptedSharedPreferences that holds
10
+ * the cross-app Oxy identity keypair.
11
+ *
12
+ * Used by BOTH [OxyIdentityModule] (the JS bridge / local read + write) and
13
+ * [OxyIdentityProvider] (the cross-process read surface) so the store name and
14
+ * the AES256 encryption scheme can never drift between the two halves.
15
+ *
16
+ * ## Single memoized instance (CRITICAL)
17
+ *
18
+ * `EncryptedSharedPreferences.create()` must be called AT MOST ONCE per file per
19
+ * process. It is NOT safe to re-instantiate: when a second instance is created
20
+ * for the same file while another is live (e.g. the JS write thread and a Binder
21
+ * thread serving [OxyIdentityProvider.call] concurrently), Tink's keyset load
22
+ * races and the next decrypt throws `AEADBadTagException` — which silently turned
23
+ * every cross-app read into "no shared identity". So the instance is created once,
24
+ * lazily, under a lock, keyed on the process-global application context, and
25
+ * reused for all reads/writes/provider calls.
26
+ */
27
+ internal object OxyIdentityStore {
28
+ const val PREFS_NAME = "oxy_shared_identity"
29
+ const val KEY_PRIVATE = "priv"
30
+ const val KEY_PUBLIC = "pub"
31
+
32
+ @Volatile private var cachedPrefs: SharedPreferences? = null
33
+
34
+ private fun prefs(context: Context): SharedPreferences {
35
+ cachedPrefs?.let { return it }
36
+ return synchronized(this) {
37
+ cachedPrefs ?: run {
38
+ val appContext = context.applicationContext
39
+ val masterKey = MasterKey.Builder(appContext)
40
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
41
+ .build()
42
+ val created = EncryptedSharedPreferences.create(
43
+ appContext,
44
+ PREFS_NAME,
45
+ masterKey,
46
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
47
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
48
+ )
49
+ cachedPrefs = created
50
+ created
51
+ }
52
+ }
53
+ }
54
+
55
+ /** Read the stored keypair as (privateKey, publicKey), or null when absent/blank. */
56
+ fun read(context: Context): Pair<String, String>? {
57
+ val p = prefs(context)
58
+ val priv = p.getString(KEY_PRIVATE, null) ?: return null
59
+ val pub = p.getString(KEY_PUBLIC, null) ?: return null
60
+ if (priv.isEmpty() || pub.isEmpty()) return null
61
+ return priv to pub
62
+ }
63
+
64
+ fun write(context: Context, priv: String, pub: String) {
65
+ // commit() (synchronous) so a cross-process reader that fires immediately
66
+ // after the write is guaranteed to see the flushed value.
67
+ prefs(context).edit()
68
+ .putString(KEY_PRIVATE, priv)
69
+ .putString(KEY_PUBLIC, pub)
70
+ .commit()
71
+ }
72
+
73
+ fun clear(context: Context) {
74
+ prefs(context).edit().clear().commit()
75
+ }
76
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["OxyIdentityModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["so.oxy.identity.OxyIdentityModule"]
8
+ }
9
+ }
@@ -0,0 +1,28 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'OxyIdentity'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = {
14
+ :ios => '16.4',
15
+ :tvos => '16.4'
16
+ }
17
+ s.swift_version = '5.9'
18
+ s.source = { git: 'https://github.com/oxyhq/sdk.git' }
19
+ s.static_framework = true
20
+
21
+ s.dependency 'ExpoModulesCore'
22
+
23
+ s.source_files = "**/*.{h,m,mm,swift}"
24
+ s.pod_target_xcconfig = {
25
+ 'DEFINES_MODULE' => 'YES',
26
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
27
+ }
28
+ end
@@ -0,0 +1,33 @@
1
+ import ExpoModulesCore
2
+
3
+ /**
4
+ * iOS no-op implementation of the shared Oxy identity bridge.
5
+ *
6
+ * On Apple platforms the cross-app identity share is handled directly by
7
+ * `@oxyhq/core`'s `KeyManager` via the Keychain Access Group
8
+ * (`group.so.oxy.shared`) — there is no ContentProvider equivalent to wrap. So
9
+ * every function here resolves to `nil` / no-op, which makes the JS
10
+ * `loadSharedIdentityBridge()` seam a pass-through on iOS: `KeyManager`'s iOS
11
+ * branches keep using `expo-secure-store` with the keychain group untouched.
12
+ */
13
+ public class OxyIdentityModule: Module {
14
+ public func definition() -> ModuleDefinition {
15
+ Name("OxyIdentity")
16
+
17
+ AsyncFunction("getShared") { () -> [String: String]? in
18
+ return nil
19
+ }
20
+
21
+ AsyncFunction("putShared") { (_ privateKey: String, _ publicKey: String) in
22
+ // No-op on iOS: the keychain-access-group path in KeyManager owns writes.
23
+ }
24
+
25
+ AsyncFunction("hasShared") { () -> Bool in
26
+ return false
27
+ }
28
+
29
+ AsyncFunction("clearShared") { () in
30
+ // No-op on iOS.
31
+ }
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "19.1.3",
3
+ "version": "19.2.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -60,12 +60,20 @@
60
60
  },
61
61
  "default": "./lib/module/ui/server.js"
62
62
  },
63
+ "./plugins/*": "./plugins/*.js",
64
+ "./expo-module.config.json": "./expo-module.config.json",
63
65
  "./package.json": "./package.json"
64
66
  },
65
67
  "files": [
66
68
  "src",
67
69
  "lib",
68
- "assets"
70
+ "assets",
71
+ "android/build.gradle",
72
+ "android/src",
73
+ "ios/OxyIdentityModule.swift",
74
+ "ios/OxyIdentity.podspec",
75
+ "plugins",
76
+ "expo-module.config.json"
69
77
  ],
70
78
  "keywords": [
71
79
  "react-native",
@@ -122,7 +130,7 @@
122
130
  "color": "^4.2.3"
123
131
  },
124
132
  "devDependencies": {
125
- "@oxyhq/core": "9.2.3",
133
+ "@oxyhq/core": "9.2.4",
126
134
  "nativewind": "5.0.0-preview.3",
127
135
  "react-native-css": "^3.0.0",
128
136
  "@react-native-async-storage/async-storage": "^2.0.0",
@@ -176,6 +184,7 @@
176
184
  "@types/react": "*",
177
185
  "@types/react-native": "*",
178
186
  "expo": ">=56.0.0",
187
+ "expo-modules-core": "*",
179
188
  "expo-document-picker": ">=56.0.0",
180
189
  "expo-file-system": ">=56.0.0",
181
190
  "expo-font": ">=13.0.0",
@@ -199,6 +208,9 @@
199
208
  "nativewind": {
200
209
  "optional": true
201
210
  },
211
+ "expo-modules-core": {
212
+ "optional": true
213
+ },
202
214
  "@expo/vector-icons": {
203
215
  "optional": true
204
216
  },
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Config plugin: withSharedIdentityProvider (Commons only).
3
+ *
4
+ * Commons is the identity vault — the ONE app that HOSTS the cross-app shared
5
+ * identity. This plugin wires the Android side of `@oxyhq/services`:
6
+ *
7
+ * - Defines a `signature`-level permission `so.oxy.shared.permission.READ_IDENTITY`.
8
+ * `signature` means only apps signed with the SAME certificate (the shared
9
+ * Oxy release keystore) can hold it — the trust boundary is the signing key,
10
+ * not the deprecated `sharedUserId`.
11
+ * - Requests that same permission (`<uses-permission>`) so Commons can also
12
+ * read cross-authority (e.g. prod ⇆ dev variant) through the provider.
13
+ * - Declares the `OxyIdentityProvider` at authority `${applicationId}.identity`
14
+ * (AGP substitutes `${applicationId}` at build → `so.oxy.commons.identity`,
15
+ * or `so.oxy.commons.dev.identity` for the dev variant), guarded by that
16
+ * permission.
17
+ * - Adds a `<queries>` entry for the provider authorities so package-visibility
18
+ * filtering (Android 11+) never hides the sibling provider from the resolver.
19
+ *
20
+ * Reader apps (accounts, Mention, …) use the companion `withSharedIdentityReader`
21
+ * plugin, which only requests the permission + queries — they never host the
22
+ * provider.
23
+ */
24
+ const { withAndroidManifest } = require('@expo/config-plugins');
25
+
26
+ const READ_IDENTITY_PERMISSION = 'so.oxy.shared.permission.READ_IDENTITY';
27
+ const PROVIDER_CLASS = 'so.oxy.identity.OxyIdentityProvider';
28
+ const PROVIDER_AUTHORITIES = ['so.oxy.commons.identity', 'so.oxy.commons.dev.identity'];
29
+
30
+ module.exports = function withSharedIdentityProvider(config) {
31
+ return withAndroidManifest(config, (modConfig) => {
32
+ const manifest = modConfig.modResults.manifest;
33
+
34
+ // 1. Define the signature-level permission.
35
+ manifest['permission'] = manifest['permission'] ?? [];
36
+ if (!manifest['permission'].some((p) => p.$['android:name'] === READ_IDENTITY_PERMISSION)) {
37
+ manifest['permission'].push({
38
+ $: {
39
+ 'android:name': READ_IDENTITY_PERMISSION,
40
+ 'android:protectionLevel': 'signature',
41
+ },
42
+ });
43
+ }
44
+
45
+ // 2. Request it (Commons reads cross-authority too).
46
+ manifest['uses-permission'] = manifest['uses-permission'] ?? [];
47
+ if (!manifest['uses-permission'].some((p) => p.$['android:name'] === READ_IDENTITY_PERMISSION)) {
48
+ manifest['uses-permission'].push({ $: { 'android:name': READ_IDENTITY_PERMISSION } });
49
+ }
50
+
51
+ // 3. Make the sibling provider authorities visible under package filtering.
52
+ manifest['queries'] = manifest['queries'] ?? [];
53
+ if (manifest['queries'].length === 0) {
54
+ manifest['queries'].push({});
55
+ }
56
+ const queries = manifest['queries'][0];
57
+ queries.provider = queries.provider ?? [];
58
+ for (const authority of PROVIDER_AUTHORITIES) {
59
+ if (!queries.provider.some((p) => p.$['android:authorities'] === authority)) {
60
+ queries.provider.push({ $: { 'android:authorities': authority } });
61
+ }
62
+ }
63
+
64
+ // 4. Host the provider.
65
+ const app = manifest.application?.[0];
66
+ if (!app) {
67
+ throw new Error('withSharedIdentityProvider: AndroidManifest has no <application>');
68
+ }
69
+ app.provider = app.provider ?? [];
70
+ if (!app.provider.some((p) => p.$['android:name'] === PROVIDER_CLASS)) {
71
+ app.provider.push({
72
+ $: {
73
+ 'android:name': PROVIDER_CLASS,
74
+ 'android:authorities': '${applicationId}.identity',
75
+ 'android:exported': 'true',
76
+ 'android:permission': READ_IDENTITY_PERMISSION,
77
+ },
78
+ });
79
+ }
80
+
81
+ return modConfig;
82
+ });
83
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Config plugin: withSharedIdentityReader (reader RPs — accounts, etc.).
3
+ *
4
+ * Reader apps do NOT host the shared identity — they only READ it from the
5
+ * Commons-hosted `OxyIdentityProvider` to enable silent "Sign in with Oxy".
6
+ * This plugin wires the minimal Android side of `@oxyhq/services`:
7
+ *
8
+ * - Requests the `signature`-level permission `so.oxy.shared.permission.READ_IDENTITY`
9
+ * (defined by Commons). Because it is `signature`, it is only granted when
10
+ * this app is signed with the SAME certificate as Commons (the shared Oxy
11
+ * release keystore) — that is the entire trust boundary.
12
+ * - Adds a `<queries>` entry for the Commons provider authorities so
13
+ * package-visibility filtering (Android 11+) never hides the provider from
14
+ * `ContentResolver.call`.
15
+ *
16
+ * The provider itself is declared only in Commons (`withSharedIdentityProvider`).
17
+ */
18
+ const { withAndroidManifest } = require('@expo/config-plugins');
19
+
20
+ const READ_IDENTITY_PERMISSION = 'so.oxy.shared.permission.READ_IDENTITY';
21
+ const PROVIDER_AUTHORITIES = ['so.oxy.commons.identity', 'so.oxy.commons.dev.identity'];
22
+
23
+ module.exports = function withSharedIdentityReader(config) {
24
+ return withAndroidManifest(config, (modConfig) => {
25
+ const manifest = modConfig.modResults.manifest;
26
+
27
+ manifest['uses-permission'] = manifest['uses-permission'] ?? [];
28
+ if (!manifest['uses-permission'].some((p) => p.$['android:name'] === READ_IDENTITY_PERMISSION)) {
29
+ manifest['uses-permission'].push({ $: { 'android:name': READ_IDENTITY_PERMISSION } });
30
+ }
31
+
32
+ manifest['queries'] = manifest['queries'] ?? [];
33
+ if (manifest['queries'].length === 0) {
34
+ manifest['queries'].push({});
35
+ }
36
+ const queries = manifest['queries'][0];
37
+ queries.provider = queries.provider ?? [];
38
+ for (const authority of PROVIDER_AUTHORITIES) {
39
+ if (!queries.provider.some((p) => p.$['android:authorities'] === authority)) {
40
+ queries.provider.push({ $: { 'android:authorities': authority } });
41
+ }
42
+ }
43
+
44
+ return modConfig;
45
+ });
46
+ };