@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.
- package/android/build.gradle +21 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/so/oxy/identity/OxyIdentityModule.kt +84 -0
- package/android/src/main/java/so/oxy/identity/OxyIdentityProvider.kt +77 -0
- package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +76 -0
- package/expo-module.config.json +9 -0
- package/ios/OxyIdentity.podspec +28 -0
- package/ios/OxyIdentityModule.swift +33 -0
- package/package.json +15 -3
- package/plugins/withSharedIdentityProvider.js +83 -0
- package/plugins/withSharedIdentityReader.js +46 -0
|
@@ -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,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.
|
|
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.
|
|
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
|
+
};
|