react-native-intune 0.1.0-alpha.1
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/LICENSE +27 -0
- package/NOTICE +49 -0
- package/README.md +164 -0
- package/RNIntune.podspec +69 -0
- package/android/build.gradle +152 -0
- package/android/consumer-rules.pro +28 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneAuth.kt +330 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneAuthCallback.kt +195 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneConfig.kt +63 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneEventQueue.kt +50 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneModule.kt +752 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneMsalConfig.kt +78 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneNotifications.kt +187 -0
- package/android/src/main/java/com/reactnativeintune/RNIntunePackage.kt +33 -0
- package/android/src/main/java/com/reactnativeintune/RNIntunePolicy.kt +108 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneReset.kt +85 -0
- package/android/src/main/java/com/reactnativeintune/RNIntuneResetJournal.kt +140 -0
- package/app.plugin.js +10 -0
- package/ios/RNIntune.h +20 -0
- package/ios/RNIntune.mm +467 -0
- package/ios/RNIntuneAuth.h +121 -0
- package/ios/RNIntuneAuth.m +390 -0
- package/ios/RNIntuneCore.h +150 -0
- package/ios/RNIntuneCore.m +535 -0
- package/ios/RNIntuneDelegates.h +70 -0
- package/ios/RNIntuneDelegates.m +268 -0
- package/ios/RNIntunePendingRequests.h +57 -0
- package/ios/RNIntunePendingRequests.m +230 -0
- package/ios/RNIntunePlistGuard.h +37 -0
- package/ios/RNIntunePlistGuard.m +147 -0
- package/ios/RNIntunePolicy.h +25 -0
- package/ios/RNIntunePolicy.m +112 -0
- package/ios/RNIntuneReset.h +34 -0
- package/ios/RNIntuneReset.m +93 -0
- package/ios/RNIntuneResetJournal.h +69 -0
- package/ios/RNIntuneResetJournal.m +106 -0
- package/lib/module/NativeIntune.js +20 -0
- package/lib/module/NativeIntune.js.map +1 -0
- package/lib/module/api/auth.js +66 -0
- package/lib/module/api/auth.js.map +1 -0
- package/lib/module/api/enrollment.js +104 -0
- package/lib/module/api/enrollment.js.map +1 -0
- package/lib/module/api/events.js +50 -0
- package/lib/module/api/events.js.map +1 -0
- package/lib/module/api/lifecycle.js +73 -0
- package/lib/module/api/lifecycle.js.map +1 -0
- package/lib/module/api/policy.js +23 -0
- package/lib/module/api/policy.js.map +1 -0
- package/lib/module/api/tokenProvider.js +80 -0
- package/lib/module/api/tokenProvider.js.map +1 -0
- package/lib/module/index.js +82 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/internal/decode.js +153 -0
- package/lib/module/internal/decode.js.map +1 -0
- package/lib/module/internal/emitter.js +15 -0
- package/lib/module/internal/emitter.js.map +1 -0
- package/lib/module/internal/errors.js +32 -0
- package/lib/module/internal/errors.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +143 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeIntune.d.ts +120 -0
- package/lib/typescript/src/NativeIntune.d.ts.map +1 -0
- package/lib/typescript/src/api/auth.d.ts +37 -0
- package/lib/typescript/src/api/auth.d.ts.map +1 -0
- package/lib/typescript/src/api/enrollment.d.ts +54 -0
- package/lib/typescript/src/api/enrollment.d.ts.map +1 -0
- package/lib/typescript/src/api/events.d.ts +24 -0
- package/lib/typescript/src/api/events.d.ts.map +1 -0
- package/lib/typescript/src/api/lifecycle.d.ts +33 -0
- package/lib/typescript/src/api/lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/api/policy.d.ts +14 -0
- package/lib/typescript/src/api/policy.d.ts.map +1 -0
- package/lib/typescript/src/api/tokenProvider.d.ts +18 -0
- package/lib/typescript/src/api/tokenProvider.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +67 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/internal/decode.d.ts +48 -0
- package/lib/typescript/src/internal/decode.d.ts.map +1 -0
- package/lib/typescript/src/internal/emitter.d.ts +11 -0
- package/lib/typescript/src/internal/emitter.d.ts.map +1 -0
- package/lib/typescript/src/internal/errors.d.ts +22 -0
- package/lib/typescript/src/internal/errors.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +317 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +204 -0
- package/plugin/transforms.js +153 -0
- package/plugin/withIntune.js +320 -0
- package/scripts/cli.mjs +72 -0
- package/scripts/doctor.mjs +232 -0
- package/scripts/fetch-sdks.mjs +456 -0
- package/scripts/lib/checks.mjs +720 -0
- package/scripts/lib/project.mjs +196 -0
- package/scripts/setup.mjs +233 -0
- package/sdk-lock.json +27 -0
- package/sdk-versions.json +70 -0
- package/src/NativeIntune.ts +130 -0
- package/src/api/auth.ts +73 -0
- package/src/api/enrollment.ts +112 -0
- package/src/api/events.ts +87 -0
- package/src/api/lifecycle.ts +75 -0
- package/src/api/policy.ts +22 -0
- package/src/api/tokenProvider.ts +80 -0
- package/src/index.ts +127 -0
- package/src/internal/decode.ts +191 -0
- package/src/internal/emitter.ts +14 -0
- package/src/internal/errors.ts +43 -0
- package/src/types.ts +381 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package com.reactnativeintune
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import java.io.File
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The per-tenant MSAL configuration, written to a file at `configure()` time (SPEC §3.3).
|
|
10
|
+
*
|
|
11
|
+
* MSAL on Android normally reads this from a raw resource, which is a build-time artifact
|
|
12
|
+
* and therefore fixed at compile time. This module configures a tenant at runtime, so the
|
|
13
|
+
* JSON is generated and handed to the `File` overload of the factory instead. That is the
|
|
14
|
+
* whole reason this file exists.
|
|
15
|
+
*
|
|
16
|
+
* Every key here was read out of MSAL 8.4.2 rather than from documentation: the shape came
|
|
17
|
+
* from `res/raw/msal_default_config.json` inside the AAR, and `authority_url` / `type` /
|
|
18
|
+
* `default` were confirmed as the serialized names on
|
|
19
|
+
* `com.microsoft.identity.common.java.authorities.Authority`. A key MSAL does not
|
|
20
|
+
* recognise is ignored silently, which is exactly the kind of failure that costs a day.
|
|
21
|
+
*/
|
|
22
|
+
internal object RNIntuneMsalConfig {
|
|
23
|
+
|
|
24
|
+
private const val FILE_NAME = "rn-intune-msal-config.json"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Writes the config and returns the file.
|
|
28
|
+
*
|
|
29
|
+
* Rewritten on every `configure()` rather than cached: the tenant can change between
|
|
30
|
+
* launches, and a stale file would point MSAL at the previous customer.
|
|
31
|
+
*/
|
|
32
|
+
fun write(
|
|
33
|
+
context: Context,
|
|
34
|
+
clientId: String,
|
|
35
|
+
authority: String,
|
|
36
|
+
redirectUri: String,
|
|
37
|
+
): File {
|
|
38
|
+
val file = File(context.filesDir, FILE_NAME)
|
|
39
|
+
file.writeText(build(clientId, authority, redirectUri).toString(2))
|
|
40
|
+
return file
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Exposed for tests and for `getDiagnostics`; contains no secrets. */
|
|
44
|
+
fun build(clientId: String, authority: String, redirectUri: String): JSONObject =
|
|
45
|
+
JSONObject().apply {
|
|
46
|
+
put("client_id", clientId)
|
|
47
|
+
put("redirect_uri", redirectUri)
|
|
48
|
+
|
|
49
|
+
// `authority_url` rather than an `audience` block: the URL is what `configure()`
|
|
50
|
+
// already carries, and it sidesteps having to map a tenant onto one of MSAL's
|
|
51
|
+
// audience type strings.
|
|
52
|
+
put(
|
|
53
|
+
"authorities",
|
|
54
|
+
JSONArray().put(
|
|
55
|
+
JSONObject().apply {
|
|
56
|
+
put("type", "AAD")
|
|
57
|
+
put("authority_url", authority)
|
|
58
|
+
put("default", true)
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// Single identity is a decided constraint, not a default we are accepting
|
|
64
|
+
// (SPEC §9, CLAUDE.md rule 10). SINGLE also gives us
|
|
65
|
+
// ISingleAccountPublicClientApplication, whose API cannot express two signed-in
|
|
66
|
+
// accounts — the constraint is enforced by the type, not by our discipline.
|
|
67
|
+
put("account_mode", "SINGLE")
|
|
68
|
+
|
|
69
|
+
// Both required for brokered auth, which MAM enrollment depends on. Without the
|
|
70
|
+
// first, MSAL will not hand the request to Company Portal at all.
|
|
71
|
+
put("broker_redirect_uri_registered", true)
|
|
72
|
+
put("authorization_user_agent", "DEFAULT")
|
|
73
|
+
|
|
74
|
+
// Sovereign clouds. Harmless for the public cloud and the difference between
|
|
75
|
+
// working and not for a customer in one (SPEC §3.3).
|
|
76
|
+
put("multiple_clouds_supported", true)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
package com.reactnativeintune
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.WritableMap
|
|
5
|
+
import com.microsoft.intune.mam.client.app.MAMComponents
|
|
6
|
+
import com.microsoft.intune.mam.client.notification.MAMNotificationReceiver
|
|
7
|
+
import com.microsoft.intune.mam.client.notification.MAMNotificationReceiverRegistry
|
|
8
|
+
import com.microsoft.intune.mam.policy.MAMEnrollmentManager
|
|
9
|
+
import com.microsoft.intune.mam.policy.notification.MAMEnrollmentNotification
|
|
10
|
+
import com.microsoft.intune.mam.policy.notification.MAMNotification
|
|
11
|
+
import com.microsoft.intune.mam.policy.notification.MAMNotificationType
|
|
12
|
+
import com.microsoft.intune.mam.policy.notification.MAMUserNotification
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Android counterpart of the iOS delegates: the SDK reports enrollment, policy and
|
|
16
|
+
* wipe through notifications rather than a delegate protocol.
|
|
17
|
+
*
|
|
18
|
+
* These arrive without the app having asked for anything — the SDK has its own
|
|
19
|
+
* enrollment retry schedule, and a wipe is service-initiated. So nothing here resolves a
|
|
20
|
+
* promise; it publishes events and whoever is waiting correlates (SPEC §6.4, §12.5).
|
|
21
|
+
*/
|
|
22
|
+
internal object RNIntuneNotifications {
|
|
23
|
+
|
|
24
|
+
/** Unified status strings. Must match `EnrollmentStatus` in src/types.ts. */
|
|
25
|
+
private const val SUCCEEDED = "succeeded"
|
|
26
|
+
private const val NOT_LICENSED = "notLicensed"
|
|
27
|
+
private const val FAILED = "failed"
|
|
28
|
+
private const val PENDING = "pending"
|
|
29
|
+
private const val AUTHORIZATION_NEEDED = "authorizationNeeded"
|
|
30
|
+
private const val COMPANY_PORTAL_REQUIRED = "companyPortalRequired"
|
|
31
|
+
private const val WRONG_USER = "wrongUser"
|
|
32
|
+
private const val UNENROLLED = "unenrolled"
|
|
33
|
+
private const val UNENROLLMENT_FAILED = "unenrollmentFailed"
|
|
34
|
+
private const val UNKNOWN = "unknown"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Maps the SDK's ten-value result onto the unified vocabulary. The reasoning for each
|
|
38
|
+
* row is SPEC §4.1 — read it before changing one. Getting `notLicensed` and `failed`
|
|
39
|
+
* the wrong way round has opposite consequences for the user (SPEC §8).
|
|
40
|
+
*
|
|
41
|
+
* `Result` is an enum, so `when` is exhaustive without an `else`: a value added by a
|
|
42
|
+
* future SDK becomes a compile error here rather than silence. Anything that does slip
|
|
43
|
+
* through still degrades to `unknown` instead of throwing.
|
|
44
|
+
*/
|
|
45
|
+
fun unifiedStatus(result: MAMEnrollmentManager.Result?): String =
|
|
46
|
+
when (result) {
|
|
47
|
+
MAMEnrollmentManager.Result.ENROLLMENT_SUCCEEDED,
|
|
48
|
+
// The account is managed through the MDM channel rather than MAM-WE. A managed,
|
|
49
|
+
// non-blocking state — nativeCode keeps the distinction for support logs.
|
|
50
|
+
MAMEnrollmentManager.Result.MDM_ENROLLED -> SUCCEEDED
|
|
51
|
+
MAMEnrollmentManager.Result.NOT_LICENSED -> NOT_LICENSED
|
|
52
|
+
MAMEnrollmentManager.Result.ENROLLMENT_FAILED -> FAILED
|
|
53
|
+
MAMEnrollmentManager.Result.PENDING -> PENDING
|
|
54
|
+
MAMEnrollmentManager.Result.AUTHORIZATION_NEEDED -> AUTHORIZATION_NEEDED
|
|
55
|
+
MAMEnrollmentManager.Result.COMPANY_PORTAL_REQUIRED -> COMPANY_PORTAL_REQUIRED
|
|
56
|
+
MAMEnrollmentManager.Result.WRONG_USER -> WRONG_USER
|
|
57
|
+
MAMEnrollmentManager.Result.UNENROLLMENT_SUCCEEDED -> UNENROLLED
|
|
58
|
+
MAMEnrollmentManager.Result.UNENROLLMENT_FAILED -> UNENROLLMENT_FAILED
|
|
59
|
+
// Android has no equivalent of iOS's LicensedNotTargeted: the service reports
|
|
60
|
+
// NOT_LICENSED for an untargeted account too, so `notTargeted` never appears from
|
|
61
|
+
// this platform. Recorded in SPEC §4.1 rather than faked here.
|
|
62
|
+
null -> UNKNOWN
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An `EnrollmentResult` built from a status the SDK already holds, rather than from a
|
|
67
|
+
* notification.
|
|
68
|
+
*
|
|
69
|
+
* Needed because `registerAccountForMAM` on an already-registered account is a silent
|
|
70
|
+
* no-op: no notification is ever posted, so `enroll()` has to answer from the registry
|
|
71
|
+
* or wait for a result that is not coming.
|
|
72
|
+
*/
|
|
73
|
+
fun resultFrom(status: MAMEnrollmentManager.Result, accountId: String? = null): WritableMap =
|
|
74
|
+
result(
|
|
75
|
+
status = unifiedStatus(status),
|
|
76
|
+
accountId = accountId,
|
|
77
|
+
nativeCode = status.name,
|
|
78
|
+
nativeMessage = "Read from the registry; the account was already registered.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/** Shapes an `EnrollmentResult` (SPEC §4.1). */
|
|
82
|
+
private fun result(
|
|
83
|
+
status: String,
|
|
84
|
+
accountId: String?,
|
|
85
|
+
nativeCode: String,
|
|
86
|
+
nativeMessage: String,
|
|
87
|
+
): WritableMap =
|
|
88
|
+
Arguments.createMap().apply {
|
|
89
|
+
putString("status", status)
|
|
90
|
+
putString("accountId", accountId)
|
|
91
|
+
putString("nativeCode", nativeCode)
|
|
92
|
+
putString("nativeMessage", nativeMessage)
|
|
93
|
+
// Android does not surface a restart requirement; iOS does, via its policy
|
|
94
|
+
// delegate. Kept in the shape so one branch in JS serves both platforms.
|
|
95
|
+
putBoolean("restartRequired", false)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Registers one receiver per notification type we act on, and returns them so they can
|
|
100
|
+
* be unregistered.
|
|
101
|
+
*
|
|
102
|
+
* `emit` is expected to queue when JS is not listening yet — a wipe notification lost
|
|
103
|
+
* during startup means the reset machine never runs (SPEC §4.4).
|
|
104
|
+
*/
|
|
105
|
+
fun register(emit: (event: String, payload: WritableMap) -> Unit): List<Registration> {
|
|
106
|
+
val registry =
|
|
107
|
+
MAMComponents.get(MAMNotificationReceiverRegistry::class.java) ?: return emptyList()
|
|
108
|
+
|
|
109
|
+
val registrations =
|
|
110
|
+
listOf(
|
|
111
|
+
// Enrollment, unenrollment and the SDK's own background retries all arrive here.
|
|
112
|
+
Registration(MAMNotificationType.MAM_ENROLLMENT_RESULT) { notification ->
|
|
113
|
+
val enrollment = notification as? MAMEnrollmentNotification ?: return@Registration true
|
|
114
|
+
val status = unifiedStatus(enrollment.enrollmentResult)
|
|
115
|
+
val payload =
|
|
116
|
+
result(
|
|
117
|
+
status = status,
|
|
118
|
+
// The Entra object ID. getUserIdentity() is the UPN and is deliberately
|
|
119
|
+
// not read (CLAUDE.md rule 3).
|
|
120
|
+
accountId = enrollment.userOid,
|
|
121
|
+
nativeCode = enrollment.enrollmentResult?.name.orEmpty(),
|
|
122
|
+
nativeMessage = enrollment.error?.toString().orEmpty(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
val event =
|
|
126
|
+
if (status == UNENROLLED || status == UNENROLLMENT_FAILED) {
|
|
127
|
+
"unenrollmentResult"
|
|
128
|
+
} else {
|
|
129
|
+
"enrollmentResult"
|
|
130
|
+
}
|
|
131
|
+
emit(event, payload)
|
|
132
|
+
true
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// An administrator changed policy. The app has to re-read whatever UI it gates.
|
|
136
|
+
Registration(MAMNotificationType.REFRESH_POLICY) {
|
|
137
|
+
emit("policyChanged", Arguments.createMap())
|
|
138
|
+
true
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// Service-initiated. Can arrive with no prior app call at all, which is the whole
|
|
142
|
+
// reason the emitter is live from construction.
|
|
143
|
+
Registration(MAMNotificationType.WIPE_USER_DATA) { notification ->
|
|
144
|
+
emit(
|
|
145
|
+
"wipeRequested",
|
|
146
|
+
Arguments.createMap().apply {
|
|
147
|
+
putString("accountId", (notification as? MAMUserNotification)?.userOid)
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
// Returning true tells the SDK the app handled the wipe. The app's own data is
|
|
151
|
+
// cleared through the reset journal, because the process may not survive long
|
|
152
|
+
// enough to do it inline (SPEC §7).
|
|
153
|
+
true
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// The account is no longer managed — policy has been removed rather than wiped.
|
|
157
|
+
Registration(MAMNotificationType.MANAGEMENT_REMOVED) { notification ->
|
|
158
|
+
emit(
|
|
159
|
+
"unenrollmentResult",
|
|
160
|
+
result(
|
|
161
|
+
status = UNENROLLED,
|
|
162
|
+
accountId = (notification as? MAMUserNotification)?.userOid,
|
|
163
|
+
nativeCode = "MANAGEMENT_REMOVED",
|
|
164
|
+
nativeMessage = "",
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
true
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
registrations.forEach { registry.registerReceiver(it.receiver, it.type) }
|
|
172
|
+
return registrations
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fun unregister(registrations: List<Registration>) {
|
|
176
|
+
val registry =
|
|
177
|
+
MAMComponents.get(MAMNotificationReceiverRegistry::class.java) ?: return
|
|
178
|
+
registrations.forEach { registry.unregisterReceiver(it.receiver, it.type) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
internal class Registration(
|
|
182
|
+
val type: MAMNotificationType,
|
|
183
|
+
handle: (MAMNotification) -> Boolean,
|
|
184
|
+
) {
|
|
185
|
+
val receiver = MAMNotificationReceiver { notification -> handle(notification) }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.reactnativeintune
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
class RNIntunePackage : BaseReactPackage() {
|
|
10
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
11
|
+
return if (name == RNIntuneModule.NAME) {
|
|
12
|
+
RNIntuneModule(reactContext)
|
|
13
|
+
} else {
|
|
14
|
+
null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
19
|
+
mapOf(
|
|
20
|
+
RNIntuneModule.NAME to ReactModuleInfo(
|
|
21
|
+
name = RNIntuneModule.NAME,
|
|
22
|
+
className = RNIntuneModule.NAME,
|
|
23
|
+
canOverrideExistingModule = false,
|
|
24
|
+
// The event emitter must be live from construction, not from the first
|
|
25
|
+
// enroll() — a service-initiated wipe can arrive before JS calls anything
|
|
26
|
+
// (SPEC §4.4, §12.4). Revisit this flag when the emitter lands.
|
|
27
|
+
needsEagerInit = false,
|
|
28
|
+
isCxxModule = false,
|
|
29
|
+
isTurboModule = true
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package com.reactnativeintune
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
5
|
+
import com.facebook.react.bridge.WritableMap
|
|
6
|
+
import com.microsoft.intune.mam.client.app.MAMComponents
|
|
7
|
+
import com.microsoft.intune.mam.client.identity.MAMPolicyManager
|
|
8
|
+
import com.microsoft.intune.mam.policy.MAMUserInfo
|
|
9
|
+
import com.microsoft.intune.mam.policy.OpenLocation
|
|
10
|
+
import com.microsoft.intune.mam.policy.SaveLocation
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PolicySnapshot (SPEC §4.3).
|
|
14
|
+
*
|
|
15
|
+
* For adapting the host app's own UI — nothing here is enforcement. PIN prompts,
|
|
16
|
+
* screenshot blocking and encryption all happen inside the SDK and are not driven by
|
|
17
|
+
* these values.
|
|
18
|
+
*
|
|
19
|
+
* There is no clipboard field and there cannot be one: neither platform exposes a
|
|
20
|
+
* clipboard policy getter. `canSaveToPersonal` and `canOpenFromUnmanaged` serve the same
|
|
21
|
+
* purpose using values the SDK will actually answer.
|
|
22
|
+
*
|
|
23
|
+
* Note the class package: `MAMPolicyManager` is in `client.identity`, not `policy`.
|
|
24
|
+
*/
|
|
25
|
+
internal object RNIntunePolicy {
|
|
26
|
+
|
|
27
|
+
fun snapshot(context: ReactApplicationContext, accountId: String?): WritableMap {
|
|
28
|
+
// Ask the SDK who the primary user is rather than relying only on the account id the
|
|
29
|
+
// caller happens to be holding: that one lives in memory and is gone after a restart,
|
|
30
|
+
// while the SDK's survives — and a policy read that silently loses its identity is
|
|
31
|
+
// exactly the failure fixed on iOS.
|
|
32
|
+
val primaryOid = runCatching { MAMComponents.get(MAMUserInfo::class.java)?.primaryUserOID }
|
|
33
|
+
.getOrNull()
|
|
34
|
+
?.takeIf { it.isNotEmpty() }
|
|
35
|
+
val oid = accountId ?: primaryOid
|
|
36
|
+
|
|
37
|
+
// `getPolicy(context)` never returns null — with no identity it answers with a
|
|
38
|
+
// default *permissive* policy. So it cannot be used to decide whether the app is
|
|
39
|
+
// managed, and `isManaged` must not be hardcoded from it being non-null. Observed on
|
|
40
|
+
// device: a fresh install that had never enrolled reported `isManaged: true`.
|
|
41
|
+
val policy = runCatching {
|
|
42
|
+
if (oid != null) MAMPolicyManager.getPolicyForIdentityOID(oid)
|
|
43
|
+
else MAMPolicyManager.getPolicy(context)
|
|
44
|
+
}.getOrNull() ?: return unmanaged(primaryOid != null)
|
|
45
|
+
|
|
46
|
+
val managed = oid != null &&
|
|
47
|
+
runCatching { MAMPolicyManager.getIsIdentityOIDManaged(oid) }.getOrDefault(false)
|
|
48
|
+
|
|
49
|
+
return Arguments.createMap().apply {
|
|
50
|
+
putBoolean("isManaged", managed)
|
|
51
|
+
putBoolean(
|
|
52
|
+
"canSaveToLocal",
|
|
53
|
+
policy.getIsSaveToLocationAllowedForOID(SaveLocation.LOCAL, oid),
|
|
54
|
+
)
|
|
55
|
+
// Not getIsSaveToPersonalAllowed(), which is deprecated — and the location form is
|
|
56
|
+
// what iOS uses too (isSaveToAllowedForLocation:Other), so both platforms answer
|
|
57
|
+
// the same question.
|
|
58
|
+
putBoolean(
|
|
59
|
+
"canSaveToPersonal",
|
|
60
|
+
policy.getIsSaveToLocationAllowedForOID(SaveLocation.OTHER, oid),
|
|
61
|
+
)
|
|
62
|
+
putBoolean(
|
|
63
|
+
"canOpenFromUnmanaged",
|
|
64
|
+
policy.getIsOpenFromLocationAllowedForOID(OpenLocation.OTHER, oid),
|
|
65
|
+
)
|
|
66
|
+
putBoolean("screenshotAllowed", policy.isScreenCaptureAllowed)
|
|
67
|
+
// Everything else the SDK reports. Explicitly outside semver: anything depended on
|
|
68
|
+
// here has to be promoted to a typed field first (SPEC §4.3).
|
|
69
|
+
putMap(
|
|
70
|
+
"raw",
|
|
71
|
+
Arguments.createMap().apply {
|
|
72
|
+
putString("isPinRequired", policy.isPinRequired.toString())
|
|
73
|
+
putString("isManagedBrowserRequired", policy.isManagedBrowserRequired.toString())
|
|
74
|
+
putString("isContactSyncAllowed", policy.isContactSyncAllowed.toString())
|
|
75
|
+
putString("hasSaveRestriction", policy.diagnosticHasSaveRestriction().toString())
|
|
76
|
+
putString("hasOpenRestriction", policy.diagnosticHasOpenRestriction().toString())
|
|
77
|
+
putString(
|
|
78
|
+
"fileEncryptionInUse",
|
|
79
|
+
policy.diagnosticIsFileEncryptionInUse().toString(),
|
|
80
|
+
)
|
|
81
|
+
// Diagnostic, not policy: an all-permissive snapshot means either no identity
|
|
82
|
+
// resolved or a genuinely unrestricted tenant, and this says which. Boolean
|
|
83
|
+
// only — the account id itself must not travel here (CLAUDE.md rule 3).
|
|
84
|
+
putString("hasPrimaryAccount", (primaryOid != null).toString())
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* An app with no policy is fully permissive. Reporting `false` everywhere would hide
|
|
92
|
+
* functionality that nothing is restricting.
|
|
93
|
+
*/
|
|
94
|
+
private fun unmanaged(hasPrimaryAccount: Boolean = false): WritableMap =
|
|
95
|
+
Arguments.createMap().apply {
|
|
96
|
+
putBoolean("isManaged", false)
|
|
97
|
+
putBoolean("canSaveToLocal", true)
|
|
98
|
+
putBoolean("canSaveToPersonal", true)
|
|
99
|
+
putBoolean("canOpenFromUnmanaged", true)
|
|
100
|
+
putBoolean("screenshotAllowed", true)
|
|
101
|
+
putMap(
|
|
102
|
+
"raw",
|
|
103
|
+
Arguments.createMap().apply {
|
|
104
|
+
putString("hasPrimaryAccount", hasPrimaryAccount.toString())
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package com.reactnativeintune
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.microsoft.intune.mam.client.app.MAMComponents
|
|
6
|
+
import com.microsoft.intune.mam.policy.MAMEnrollmentManager
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The reset sequence (SPEC §7).
|
|
10
|
+
*
|
|
11
|
+
* Separate from [RNIntuneResetJournal] on purpose: the journal is storage, this is the
|
|
12
|
+
* order of operations. The order is the part that is easy to get wrong — the unregister
|
|
13
|
+
* has to precede any token purge, and the process is not guaranteed to survive it.
|
|
14
|
+
*/
|
|
15
|
+
internal object RNIntuneReset {
|
|
16
|
+
|
|
17
|
+
private const val TAG = "RNIntuneReset"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Opens the journal, then unregisters. Every step is written down *before* it is
|
|
21
|
+
* attempted, because after `unregisterAccountForMAM` there may be no process left to
|
|
22
|
+
* write anything.
|
|
23
|
+
*/
|
|
24
|
+
fun run(
|
|
25
|
+
context: Context,
|
|
26
|
+
accountId: String?,
|
|
27
|
+
upn: String?,
|
|
28
|
+
tenantId: String?,
|
|
29
|
+
wipe: Boolean,
|
|
30
|
+
reason: String,
|
|
31
|
+
) {
|
|
32
|
+
RNIntuneResetJournal.open(
|
|
33
|
+
context = context,
|
|
34
|
+
accountId = accountId,
|
|
35
|
+
upn = upn,
|
|
36
|
+
tenantId = tenantId,
|
|
37
|
+
wipe = wipe,
|
|
38
|
+
reason = reason,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (upn != null) {
|
|
42
|
+
val manager = MAMComponents.get(MAMEnrollmentManager::class.java)
|
|
43
|
+
// `unregisterAccountForMAM(upn, oid)`. The second parameter is the object ID —
|
|
44
|
+
// identified from bytecode, not documentation: the offline implementation checks
|
|
45
|
+
// its second argument and warns "called without valid OID; identity may be
|
|
46
|
+
// ambiguous", then calls `MAMIdentityManager.create(arg1, arg2)`.
|
|
47
|
+
//
|
|
48
|
+
// The single-argument form is not merely deprecated, it is worse: it leaves the
|
|
49
|
+
// identity ambiguous, and that warning was visible in logcat on a real device
|
|
50
|
+
// before this was fixed. `getRegisteredAccountStatus` has the same shape.
|
|
51
|
+
runCatching { manager?.unregisterAccountForMAM(upn, accountId.orEmpty()) }
|
|
52
|
+
.onFailure { Log.w(TAG, "unregisterAccountForMAM failed: ${it.message}") }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Reached only if the process survived.
|
|
56
|
+
RNIntuneResetJournal.advance(context, RNIntuneResetJournal.STAGE_CLEANING_AUTH)
|
|
57
|
+
|
|
58
|
+
// MSAL cache cleanup happens in RNIntuneModule.doReset, immediately after this
|
|
59
|
+
// returns, and not here: this object has no MSAL client and should not grow a
|
|
60
|
+
// dependency on one — it owns the order of operations, not the pieces. What matters
|
|
61
|
+
// is that it comes *after* the unregister above, because that call needs an Intune
|
|
62
|
+
// token which comes from the very cache being cleared (SPEC §7 step 4).
|
|
63
|
+
|
|
64
|
+
RNIntuneResetJournal.advance(context, RNIntuneResetJournal.STAGE_CLEANING_LOCAL)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Closes the journal, but only once the account is verified gone.
|
|
69
|
+
*
|
|
70
|
+
* @param statusOf reads the SDK's registration status for an account ID.
|
|
71
|
+
* @return false when the reset did not take, leaving the journal open for a retry.
|
|
72
|
+
*/
|
|
73
|
+
fun complete(
|
|
74
|
+
context: Context,
|
|
75
|
+
statusOf: (String) -> MAMEnrollmentManager.Result?,
|
|
76
|
+
): Boolean {
|
|
77
|
+
val entry = RNIntuneResetJournal.read(context) ?: return true
|
|
78
|
+
|
|
79
|
+
val stillRegistered = entry.accountId?.let { statusOf(it) != null } ?: false
|
|
80
|
+
if (stillRegistered) return false
|
|
81
|
+
|
|
82
|
+
RNIntuneResetJournal.close(context)
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
package com.reactnativeintune
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The reset journal (SPEC §7).
|
|
8
|
+
*
|
|
9
|
+
* Unregistering an account that had policy enforced makes the SDK wipe that account's
|
|
10
|
+
* data, and **the process terminating is expected behaviour, not a crash**. Code after
|
|
11
|
+
* the unregister call does not run. So the sequence is not held in memory: each stage is
|
|
12
|
+
* written down before it is attempted, and the next launch reads what was in flight.
|
|
13
|
+
*
|
|
14
|
+
* IDLE -> UNREGISTERING -> CLEANING_AUTH -> CLEANING_LOCAL -> IDLE
|
|
15
|
+
*
|
|
16
|
+
* No SDK dependency: the journal has to be readable at launch, before anything is
|
|
17
|
+
* configured.
|
|
18
|
+
*
|
|
19
|
+
* **On the storage choice.** SPEC §7 requires this to survive the wipe, which on Android
|
|
20
|
+
* means it must carry no identity tag — a tagged file is deleted with the account's data
|
|
21
|
+
* and the app restarts with no knowledge that a reset was in flight. These preferences
|
|
22
|
+
* are written without ever setting a thread or process identity (the module never calls
|
|
23
|
+
* `setCurrentThreadAccountId`, per CLAUDE.md rule 10), so the MAM plugin's rewritten file
|
|
24
|
+
* layer sees them as unmanaged.
|
|
25
|
+
*
|
|
26
|
+
* [verify] against a real wipe once a tenant exists. Survival across process death is
|
|
27
|
+
* exercised and works; survival across a wipe cannot be tested without a policy-targeted
|
|
28
|
+
* account, and it is the half that matters more.
|
|
29
|
+
*/
|
|
30
|
+
internal object RNIntuneResetJournal {
|
|
31
|
+
|
|
32
|
+
/** Stage strings. Must match `ResetStage` in src/types.ts. */
|
|
33
|
+
const val STAGE_UNREGISTERING = "unregistering"
|
|
34
|
+
const val STAGE_CLEANING_AUTH = "cleaningAuth"
|
|
35
|
+
const val STAGE_CLEANING_LOCAL = "cleaningLocal"
|
|
36
|
+
|
|
37
|
+
private const val PREFS = "com.reactnativeintune.resetJournal"
|
|
38
|
+
private const val KEY_STAGE = "stage"
|
|
39
|
+
private const val KEY_ACCOUNT_ID = "accountId"
|
|
40
|
+
private const val KEY_UPN = "upn"
|
|
41
|
+
private const val KEY_TENANT_ID = "tenantId"
|
|
42
|
+
private const val KEY_WIPE = "wipe"
|
|
43
|
+
private const val KEY_REASON = "reason"
|
|
44
|
+
private const val KEY_STARTED_AT = "startedAt"
|
|
45
|
+
|
|
46
|
+
data class Entry(
|
|
47
|
+
val stage: String,
|
|
48
|
+
val accountId: String?,
|
|
49
|
+
val upn: String?,
|
|
50
|
+
val tenantId: String?,
|
|
51
|
+
val wipe: Boolean,
|
|
52
|
+
val reason: String,
|
|
53
|
+
val startedAt: Long,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
private fun prefs(context: Context): SharedPreferences =
|
|
57
|
+
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
58
|
+
|
|
59
|
+
fun read(context: Context): Entry? {
|
|
60
|
+
val p = prefs(context)
|
|
61
|
+
val stage = p.getString(KEY_STAGE, null) ?: return null
|
|
62
|
+
return Entry(
|
|
63
|
+
stage = stage,
|
|
64
|
+
accountId = p.getString(KEY_ACCOUNT_ID, null),
|
|
65
|
+
upn = p.getString(KEY_UPN, null),
|
|
66
|
+
tenantId = p.getString(KEY_TENANT_ID, null),
|
|
67
|
+
wipe = p.getBoolean(KEY_WIPE, false),
|
|
68
|
+
reason = p.getString(KEY_REASON, "").orEmpty(),
|
|
69
|
+
startedAt = p.getLong(KEY_STARTED_AT, 0L),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun stage(context: Context): String? = prefs(context).getString(KEY_STAGE, null)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Writes the entry at [STAGE_UNREGISTERING]. Uses `commit()` rather than `apply()`
|
|
77
|
+
* deliberately: the caller is about to make a call that may end the process, and an
|
|
78
|
+
* entry still queued for a background write is an entry that never existed.
|
|
79
|
+
*/
|
|
80
|
+
fun open(
|
|
81
|
+
context: Context,
|
|
82
|
+
accountId: String?,
|
|
83
|
+
upn: String?,
|
|
84
|
+
tenantId: String?,
|
|
85
|
+
wipe: Boolean,
|
|
86
|
+
reason: String,
|
|
87
|
+
) {
|
|
88
|
+
prefs(context)
|
|
89
|
+
.edit()
|
|
90
|
+
.putString(KEY_STAGE, STAGE_UNREGISTERING)
|
|
91
|
+
.putString(KEY_ACCOUNT_ID, accountId)
|
|
92
|
+
.putString(KEY_UPN, upn)
|
|
93
|
+
.putString(KEY_TENANT_ID, tenantId)
|
|
94
|
+
.putBoolean(KEY_WIPE, wipe)
|
|
95
|
+
.putString(KEY_REASON, reason)
|
|
96
|
+
.putLong(KEY_STARTED_AT, System.currentTimeMillis())
|
|
97
|
+
.commit()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Opens the journal for a wipe the *service* started, unless one is already open.
|
|
102
|
+
*
|
|
103
|
+
* A service-initiated wipe is one of the callers of the reset path (SPEC §7, "one code
|
|
104
|
+
* path, many callers"), and it needs the journal for the same reason `reset()` does: the
|
|
105
|
+
* SDK terminates the process, so the consumer's cleanup may not finish and nothing else
|
|
106
|
+
* will come back for it. With no entry there is no `pendingReset`, so the next launch
|
|
107
|
+
* has no idea anything happened.
|
|
108
|
+
*
|
|
109
|
+
* Returns false when an entry already exists — a reset in flight must not be
|
|
110
|
+
* overwritten, since its `reason` and `accountId` are what the resumed sequence acts on.
|
|
111
|
+
*/
|
|
112
|
+
fun openForServiceWipe(context: Context, accountId: String?, tenantId: String?): Boolean {
|
|
113
|
+
if (read(context) != null) return false
|
|
114
|
+
// `wipe = true` records what happened rather than requesting it: the SDK is already
|
|
115
|
+
// wiping. `reason` is what lets the consumer's handler tell an administrator revoking
|
|
116
|
+
// access from a user logging out (SPEC §7.4).
|
|
117
|
+
open(
|
|
118
|
+
context = context,
|
|
119
|
+
accountId = accountId,
|
|
120
|
+
upn = null,
|
|
121
|
+
tenantId = tenantId,
|
|
122
|
+
wipe = true,
|
|
123
|
+
reason = "remote_wipe",
|
|
124
|
+
)
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun advance(context: Context, stage: String) {
|
|
129
|
+
if (prefs(context).getString(KEY_STAGE, null) == null) return
|
|
130
|
+
prefs(context).edit().putString(KEY_STAGE, stage).commit()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Only after the account is verified gone. An unverified reset stays open and is
|
|
135
|
+
* retried on the next launch rather than being marked done (SPEC §7 step 5).
|
|
136
|
+
*/
|
|
137
|
+
fun close(context: Context) {
|
|
138
|
+
prefs(context).edit().clear().commit()
|
|
139
|
+
}
|
|
140
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo's entry point for this package's config plugin.
|
|
3
|
+
*
|
|
4
|
+
* `app.plugin.js` at the package root is the convention Expo looks for, which is what
|
|
5
|
+
* lets a consumer write the package name alone in their `plugins` array rather than a
|
|
6
|
+
* path into it:
|
|
7
|
+
*
|
|
8
|
+
* "plugins": [["react-native-intune", { "androidSignatureHash": "..." }]]
|
|
9
|
+
*/
|
|
10
|
+
module.exports = require('./plugin/withIntune');
|
package/ios/RNIntune.h
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Codegen conformance and the JS bridge. ObjC++ because getTurboModule: returns a
|
|
3
|
+
// std::shared_ptr and the generated parameter structs are C++ — neither can be written
|
|
4
|
+
// in Objective-C or Swift (SPEC §5.1.1).
|
|
5
|
+
//
|
|
6
|
+
// This class unpacks and forwards. It holds no SDK state and makes no SDK calls; that
|
|
7
|
+
// is RNIntuneCore's job, in plain .m (CLAUDE.md rule 7).
|
|
8
|
+
//
|
|
9
|
+
// The umbrella header name comes from package.json -> codegenConfig.name; the protocol
|
|
10
|
+
// name from the spec file name (src/NativeIntune.ts -> NativeIntuneSpec).
|
|
11
|
+
//
|
|
12
|
+
#import <RNIntuneSpec/RNIntuneSpec.h>
|
|
13
|
+
#import <React/RCTEventEmitter.h>
|
|
14
|
+
|
|
15
|
+
/// RCTEventEmitter, not NSObject: the emitter has to be live from module construction
|
|
16
|
+
/// because a service-initiated wipe can arrive before the app calls anything, and events
|
|
17
|
+
/// fired before JS subscribes are queued rather than dropped (SPEC §4.4).
|
|
18
|
+
@interface RNIntune : RCTEventEmitter <NativeIntuneSpec>
|
|
19
|
+
|
|
20
|
+
@end
|