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.
Files changed (110) hide show
  1. package/LICENSE +27 -0
  2. package/NOTICE +49 -0
  3. package/README.md +164 -0
  4. package/RNIntune.podspec +69 -0
  5. package/android/build.gradle +152 -0
  6. package/android/consumer-rules.pro +28 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/reactnativeintune/RNIntuneAuth.kt +330 -0
  9. package/android/src/main/java/com/reactnativeintune/RNIntuneAuthCallback.kt +195 -0
  10. package/android/src/main/java/com/reactnativeintune/RNIntuneConfig.kt +63 -0
  11. package/android/src/main/java/com/reactnativeintune/RNIntuneEventQueue.kt +50 -0
  12. package/android/src/main/java/com/reactnativeintune/RNIntuneModule.kt +752 -0
  13. package/android/src/main/java/com/reactnativeintune/RNIntuneMsalConfig.kt +78 -0
  14. package/android/src/main/java/com/reactnativeintune/RNIntuneNotifications.kt +187 -0
  15. package/android/src/main/java/com/reactnativeintune/RNIntunePackage.kt +33 -0
  16. package/android/src/main/java/com/reactnativeintune/RNIntunePolicy.kt +108 -0
  17. package/android/src/main/java/com/reactnativeintune/RNIntuneReset.kt +85 -0
  18. package/android/src/main/java/com/reactnativeintune/RNIntuneResetJournal.kt +140 -0
  19. package/app.plugin.js +10 -0
  20. package/ios/RNIntune.h +20 -0
  21. package/ios/RNIntune.mm +467 -0
  22. package/ios/RNIntuneAuth.h +121 -0
  23. package/ios/RNIntuneAuth.m +390 -0
  24. package/ios/RNIntuneCore.h +150 -0
  25. package/ios/RNIntuneCore.m +535 -0
  26. package/ios/RNIntuneDelegates.h +70 -0
  27. package/ios/RNIntuneDelegates.m +268 -0
  28. package/ios/RNIntunePendingRequests.h +57 -0
  29. package/ios/RNIntunePendingRequests.m +230 -0
  30. package/ios/RNIntunePlistGuard.h +37 -0
  31. package/ios/RNIntunePlistGuard.m +147 -0
  32. package/ios/RNIntunePolicy.h +25 -0
  33. package/ios/RNIntunePolicy.m +112 -0
  34. package/ios/RNIntuneReset.h +34 -0
  35. package/ios/RNIntuneReset.m +93 -0
  36. package/ios/RNIntuneResetJournal.h +69 -0
  37. package/ios/RNIntuneResetJournal.m +106 -0
  38. package/lib/module/NativeIntune.js +20 -0
  39. package/lib/module/NativeIntune.js.map +1 -0
  40. package/lib/module/api/auth.js +66 -0
  41. package/lib/module/api/auth.js.map +1 -0
  42. package/lib/module/api/enrollment.js +104 -0
  43. package/lib/module/api/enrollment.js.map +1 -0
  44. package/lib/module/api/events.js +50 -0
  45. package/lib/module/api/events.js.map +1 -0
  46. package/lib/module/api/lifecycle.js +73 -0
  47. package/lib/module/api/lifecycle.js.map +1 -0
  48. package/lib/module/api/policy.js +23 -0
  49. package/lib/module/api/policy.js.map +1 -0
  50. package/lib/module/api/tokenProvider.js +80 -0
  51. package/lib/module/api/tokenProvider.js.map +1 -0
  52. package/lib/module/index.js +82 -0
  53. package/lib/module/index.js.map +1 -0
  54. package/lib/module/internal/decode.js +153 -0
  55. package/lib/module/internal/decode.js.map +1 -0
  56. package/lib/module/internal/emitter.js +15 -0
  57. package/lib/module/internal/emitter.js.map +1 -0
  58. package/lib/module/internal/errors.js +32 -0
  59. package/lib/module/internal/errors.js.map +1 -0
  60. package/lib/module/package.json +1 -0
  61. package/lib/module/types.js +143 -0
  62. package/lib/module/types.js.map +1 -0
  63. package/lib/typescript/package.json +1 -0
  64. package/lib/typescript/src/NativeIntune.d.ts +120 -0
  65. package/lib/typescript/src/NativeIntune.d.ts.map +1 -0
  66. package/lib/typescript/src/api/auth.d.ts +37 -0
  67. package/lib/typescript/src/api/auth.d.ts.map +1 -0
  68. package/lib/typescript/src/api/enrollment.d.ts +54 -0
  69. package/lib/typescript/src/api/enrollment.d.ts.map +1 -0
  70. package/lib/typescript/src/api/events.d.ts +24 -0
  71. package/lib/typescript/src/api/events.d.ts.map +1 -0
  72. package/lib/typescript/src/api/lifecycle.d.ts +33 -0
  73. package/lib/typescript/src/api/lifecycle.d.ts.map +1 -0
  74. package/lib/typescript/src/api/policy.d.ts +14 -0
  75. package/lib/typescript/src/api/policy.d.ts.map +1 -0
  76. package/lib/typescript/src/api/tokenProvider.d.ts +18 -0
  77. package/lib/typescript/src/api/tokenProvider.d.ts.map +1 -0
  78. package/lib/typescript/src/index.d.ts +67 -0
  79. package/lib/typescript/src/index.d.ts.map +1 -0
  80. package/lib/typescript/src/internal/decode.d.ts +48 -0
  81. package/lib/typescript/src/internal/decode.d.ts.map +1 -0
  82. package/lib/typescript/src/internal/emitter.d.ts +11 -0
  83. package/lib/typescript/src/internal/emitter.d.ts.map +1 -0
  84. package/lib/typescript/src/internal/errors.d.ts +22 -0
  85. package/lib/typescript/src/internal/errors.d.ts.map +1 -0
  86. package/lib/typescript/src/types.d.ts +317 -0
  87. package/lib/typescript/src/types.d.ts.map +1 -0
  88. package/package.json +204 -0
  89. package/plugin/transforms.js +153 -0
  90. package/plugin/withIntune.js +320 -0
  91. package/scripts/cli.mjs +72 -0
  92. package/scripts/doctor.mjs +232 -0
  93. package/scripts/fetch-sdks.mjs +456 -0
  94. package/scripts/lib/checks.mjs +720 -0
  95. package/scripts/lib/project.mjs +196 -0
  96. package/scripts/setup.mjs +233 -0
  97. package/sdk-lock.json +27 -0
  98. package/sdk-versions.json +70 -0
  99. package/src/NativeIntune.ts +130 -0
  100. package/src/api/auth.ts +73 -0
  101. package/src/api/enrollment.ts +112 -0
  102. package/src/api/events.ts +87 -0
  103. package/src/api/lifecycle.ts +75 -0
  104. package/src/api/policy.ts +22 -0
  105. package/src/api/tokenProvider.ts +80 -0
  106. package/src/index.ts +127 -0
  107. package/src/internal/decode.ts +191 -0
  108. package/src/internal/emitter.ts +14 -0
  109. package/src/internal/errors.ts +43 -0
  110. package/src/types.ts +381 -0
@@ -0,0 +1,330 @@
1
+ package com.reactnativeintune
2
+
3
+ import android.app.Activity
4
+ import com.facebook.react.bridge.Arguments
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.WritableMap
7
+ import com.microsoft.identity.client.AcquireTokenSilentParameters
8
+ import com.microsoft.identity.client.AuthenticationCallback
9
+ import com.microsoft.identity.client.IAccount
10
+ import com.microsoft.identity.client.IAuthenticationResult
11
+ import com.microsoft.identity.client.ISingleAccountPublicClientApplication
12
+ import com.microsoft.identity.client.Prompt
13
+ import com.microsoft.identity.client.PublicClientApplication
14
+ import com.microsoft.identity.client.SignInParameters
15
+ import com.microsoft.identity.client.exception.MsalException
16
+ import com.microsoft.identity.client.exception.MsalUiRequiredException
17
+ import com.microsoft.identity.client.exception.MsalUserCancelException
18
+
19
+ /**
20
+ * MSAL: the client, sign-in, and tokens for the app's own scopes (SPEC §3, spike S-3).
21
+ *
22
+ * The iOS counterpart is `ios/RNIntuneAuth.m`, and the two answer the same questions with
23
+ * the same error codes. The mechanics differ in one way worth knowing: on iOS the module
24
+ * builds an `MSALPublicClientApplication` from objects, while here MSAL insists on reading
25
+ * a JSON config, so `RNIntuneMsalConfig` writes one at `configure()` time (SPEC §3.3).
26
+ *
27
+ * Two tokens exist and only one of them leaves this file. The MAM service token is
28
+ * acquired by `RNIntuneAuthCallback`, handed to the SDK, and never returned to JS
29
+ * (CLAUDE.md rule 9). Everything here is for the *app's* scopes (SPEC §3.5).
30
+ */
31
+ internal class RNIntuneAuth(private val reactContext: ReactApplicationContext) {
32
+
33
+ private var client: ISingleAccountPublicClientApplication? = null
34
+
35
+ val isConfigured: Boolean
36
+ get() = client != null
37
+
38
+ companion object {
39
+ /**
40
+ * What a sign-in asks for when the caller names no scopes.
41
+ *
42
+ * MSAL leaves no cheaper option: it adds `openid`, `profile` and `offline_access`
43
+ * itself and rejects a call that names them, so a sign-in must request some real
44
+ * resource. `User.Read` is the permission Entra grants every new app registration,
45
+ * and it is what Microsoft's own samples use to mean "just sign in".
46
+ *
47
+ * Which scope fetched the token does not matter for enrollment: the refresh token is
48
+ * per account, not per resource, and the MAM token is acquired separately.
49
+ */
50
+ val DEFAULT_SCOPES: List<String> = listOf("https://graph.microsoft.com/User.Read")
51
+ }
52
+
53
+ /**
54
+ * Builds the MSAL client for one tenant. Blocking, and it does disk and network setup —
55
+ * the caller runs it off the main thread.
56
+ *
57
+ * Throws rather than returning a flag so `configure()` fails loudly here instead of at
58
+ * the first sign-in, where the cause is much harder to see.
59
+ */
60
+ @Throws(MsalException::class, InterruptedException::class)
61
+ fun configure(clientId: String, authority: String, redirectUri: String) {
62
+ val file = RNIntuneMsalConfig.write(reactContext, clientId, authority, redirectUri)
63
+ client = PublicClientApplication.createSingleAccountPublicClientApplication(
64
+ reactContext.applicationContext,
65
+ file,
66
+ )
67
+ }
68
+
69
+ /** Drops the client. Called from reset, which must leave nothing pinned to the tenant. */
70
+ fun invalidate() {
71
+ client = null
72
+ }
73
+
74
+ // ------------------------------------------------------------------------ sign-in
75
+
76
+ /**
77
+ * Interactive sign-in. Uses Company Portal as the broker when present, which is what
78
+ * makes the resulting token acceptable to the MAM service.
79
+ *
80
+ * Needs a foreground Activity, and there may not be one — React Native survives its
81
+ * Activity being destroyed, so this is a real failure mode rather than a formality.
82
+ */
83
+ fun signIn(
84
+ scopes: List<String>,
85
+ loginHint: String?,
86
+ prompt: String?,
87
+ onResult: (WritableMap) -> Unit,
88
+ onError: (String, String) -> Unit,
89
+ ) {
90
+ val app = client ?: return onError(
91
+ RNIntuneModule.ERR_NOT_CONFIGURED,
92
+ "MSAL is not configured. Call configure first.",
93
+ )
94
+ val activity: Activity = reactContext.currentActivity ?: return onError(
95
+ RNIntuneModule.ERR_NO_ACTIVITY,
96
+ "signIn needs a foreground Activity and there is none. Call it from a mounted " +
97
+ "screen rather than during startup or from the background.",
98
+ )
99
+
100
+ val params = SignInParameters.builder()
101
+ .withActivity(activity)
102
+ .withScopes(if (scopes.isEmpty()) DEFAULT_SCOPES else scopes)
103
+ .withPrompt(promptFrom(prompt))
104
+ .withCallback(callback(onResult, onError))
105
+ .apply { if (!loginHint.isNullOrEmpty()) withLoginHint(loginHint) }
106
+ .build()
107
+
108
+ // A signed-in account changes which call is legal, and the wrong one fails outright:
109
+ // `signIn` throws "An account is already signed in." in SINGLE account mode. iOS has
110
+ // no such rule — `acquireTokenWithParameters:` works either way — so smoothing this
111
+ // over belongs here rather than in every consumer.
112
+ val existing = currentAccount()
113
+ if (existing == null) {
114
+ app.signIn(params)
115
+ return
116
+ }
117
+
118
+ // Already signed in: answer from the cache, which is also the better experience —
119
+ // no UI at all for the common case of an app restart.
120
+ val silent = AcquireTokenSilentParameters.Builder()
121
+ .withScopes(if (scopes.isEmpty()) DEFAULT_SCOPES else scopes)
122
+ .forAccount(existing)
123
+ .fromAuthority(existing.authority)
124
+ .build()
125
+
126
+ val cached = runCatching { app.acquireTokenSilent(silent) }.getOrNull()
127
+ if (cached != null) {
128
+ onResult(toAuthResult(cached))
129
+ return
130
+ }
131
+
132
+ // The cache could not answer, so re-authenticate the account that is already there.
133
+ // `signInAgain` rather than `signIn`, which is the same distinction as above.
134
+ app.signInAgain(params)
135
+ }
136
+
137
+ /**
138
+ * Cache-first. Fails with `E_INTERACTION_REQUIRED` when a prompt is needed — the
139
+ * caller's pattern is silent first, interactive on that failure.
140
+ *
141
+ * Blocking, so the caller runs it off the main thread. The async variants exist, but
142
+ * every caller here is already on a background thread and the blocking form keeps the
143
+ * error handling in one place.
144
+ */
145
+ fun acquireTokenSilent(
146
+ scopes: List<String>,
147
+ forceRefresh: Boolean,
148
+ onResult: (WritableMap) -> Unit,
149
+ onError: (String, String) -> Unit,
150
+ ) {
151
+ val app = client ?: return onError(
152
+ RNIntuneModule.ERR_NOT_CONFIGURED,
153
+ "MSAL is not configured. Call configure first.",
154
+ )
155
+
156
+ val account = currentAccount()
157
+ if (account == null) {
158
+ // Not a fault: with no cached account there is nothing to refresh, and the caller's
159
+ // next move is the same as for an expired token.
160
+ onError(
161
+ RNIntuneModule.ERR_INTERACTION_REQUIRED,
162
+ "No signed-in account is cached. Call signIn to acquire one.",
163
+ )
164
+ return
165
+ }
166
+
167
+ val params = AcquireTokenSilentParameters.Builder()
168
+ .withScopes(if (scopes.isEmpty()) DEFAULT_SCOPES else scopes)
169
+ .forAccount(account)
170
+ .fromAuthority(account.authority)
171
+ .forceRefresh(forceRefresh)
172
+ .build()
173
+
174
+ try {
175
+ onResult(toAuthResult(app.acquireTokenSilent(params)))
176
+ } catch (e: Throwable) {
177
+ val (code, message) = map(e)
178
+ onError(code, message)
179
+ }
180
+ }
181
+
182
+ /**
183
+ * A MAM service token, acquired silently for the SDK.
184
+ *
185
+ * This is the Android half of "the module owns MAM token acquisition" (CLAUDE.md rule
186
+ * 8), and it exists because Android gives no equivalent of the iOS escape hatch. There,
187
+ * the module simply does not implement the token delegate and the SDK helps itself from
188
+ * the shared MSAL cache. Here `MAMServiceAuthenticationCallback` is mandatory and the
189
+ * SDK always asks the app, so in `builtin` mode the app has to answer — from the same
190
+ * MSAL instance, without involving JS.
191
+ *
192
+ * Blocking, and called on the SDK's own background thread, which is what it wants: the
193
+ * callback must return the token synchronously.
194
+ *
195
+ * The return value is a token. It is handed to the SDK and nothing else — never logged,
196
+ * never stored, never returned across the bridge (CLAUDE.md rule 9). The `null` path
197
+ * deliberately carries no detail for the same reason.
198
+ */
199
+ fun mamServiceToken(resourceId: String): String? {
200
+ val app = client ?: return null
201
+ val account = currentAccount() ?: return null
202
+
203
+ // `<resource>/.default` is how MSAL asks for every statically-granted permission on a
204
+ // resource, which is what the MAM service expects. The SDK passes the resource as an
205
+ // app id GUID; the constant form in RNIntuneAuthCallback is the same resource written
206
+ // as a URI, and either is accepted here.
207
+ val params = AcquireTokenSilentParameters.Builder()
208
+ .withScopes(listOf("$resourceId/.default"))
209
+ .forAccount(account)
210
+ .fromAuthority(account.authority)
211
+ .build()
212
+
213
+ return runCatching { app.acquireTokenSilent(params).accessToken }.getOrNull()
214
+ }
215
+
216
+ // ------------------------------------------------------------------------ accounts
217
+
218
+ /**
219
+ * The signed-in account as an `AuthAccount` list, or an empty one.
220
+ *
221
+ * A list of at most one, because the client is SINGLE-account mode — the shape matches
222
+ * iOS and the public API, where more than one entry would mean state a reset should
223
+ * have cleared (SPEC §9).
224
+ */
225
+ fun accounts(): List<WritableMap> {
226
+ val account = currentAccount() ?: return emptyList()
227
+ return listOf(
228
+ Arguments.createMap().apply {
229
+ putString("accountId", objectIdOf(account))
230
+ putString("tenantId", account.tenantId.orEmpty())
231
+ putString("username", account.username.orEmpty())
232
+ },
233
+ )
234
+ }
235
+
236
+ /**
237
+ * The signed-in account's UPN, which Android enrollment needs alongside the object ID.
238
+ *
239
+ * This is the seam `RNIntuneModule.knownUpnFor` was left for:
240
+ * `registerAccountForMAM` takes both and will not accept a null UPN, and the public API
241
+ * deliberately carries only the object ID (SPEC §6.3).
242
+ */
243
+ fun upnFor(accountId: String): String? {
244
+ val account = currentAccount() ?: return null
245
+ return if (objectIdOf(account) == accountId) account.username else null
246
+ }
247
+
248
+ /**
249
+ * Removes the account and its tokens from the MSAL cache — the half of reset the module
250
+ * could not do while MSAL lived in the host app (SPEC §7 step 4).
251
+ *
252
+ * Blocking. Errors are swallowed on purpose: this runs inside the reset sequence, where
253
+ * the journal's verification decides whether the reset took, not this call.
254
+ */
255
+ fun signOutQuietly() {
256
+ runCatching { client?.signOut() }
257
+ }
258
+
259
+ // ------------------------------------------------------------------------ internals
260
+
261
+ private fun currentAccount(): IAccount? =
262
+ runCatching { client?.currentAccount?.currentAccount }.getOrNull()
263
+
264
+ /**
265
+ * The Entra object ID, which is what `enroll()` and the Intune SDK want.
266
+ *
267
+ * `oid` from the claims is the authoritative source; `getId()` is the fallback because
268
+ * for an AAD account MSAL populates it from the same value. Guessing wrong here does not
269
+ * fail loudly — it registers the wrong identity — so both are consulted.
270
+ */
271
+ private fun objectIdOf(account: IAccount): String {
272
+ val fromClaims = account.claims?.get("oid")?.toString()
273
+ return if (!fromClaims.isNullOrEmpty()) fromClaims else account.id.orEmpty()
274
+ }
275
+
276
+ private fun toAuthResult(result: IAuthenticationResult): WritableMap =
277
+ Arguments.createMap().apply {
278
+ putString("accountId", objectIdOf(result.account))
279
+ putString("tenantId", result.tenantId.orEmpty())
280
+ putString("username", result.account.username.orEmpty())
281
+ putString("accessToken", result.accessToken)
282
+ putString("idToken", result.account.idToken)
283
+ // Unix seconds, matching AuthResult in SPEC §13.1.1 and the iOS implementation.
284
+ putDouble("expiresOn", (result.expiresOn?.time ?: 0L) / 1000.0)
285
+ putArray(
286
+ "scopes",
287
+ Arguments.createArray().apply { result.scope?.forEach { pushString(it) } },
288
+ )
289
+ }
290
+
291
+ private fun callback(
292
+ onResult: (WritableMap) -> Unit,
293
+ onError: (String, String) -> Unit,
294
+ ) = object : AuthenticationCallback {
295
+ override fun onSuccess(result: IAuthenticationResult) = onResult(toAuthResult(result))
296
+
297
+ override fun onError(exception: MsalException) {
298
+ val (code, message) = map(exception)
299
+ onError(code, message)
300
+ }
301
+
302
+ override fun onCancel() {
303
+ onError(
304
+ RNIntuneModule.ERR_USER_CANCELLED,
305
+ "The user dismissed the sign-in UI.",
306
+ )
307
+ }
308
+ }
309
+
310
+ private fun promptFrom(prompt: String?): Prompt = when (prompt) {
311
+ "selectAccount" -> Prompt.SELECT_ACCOUNT
312
+ "login" -> Prompt.LOGIN
313
+ "consent" -> Prompt.CONSENT
314
+ "whenRequired" -> Prompt.WHEN_REQUIRED
315
+ // Including null. An unrecognised value must not fail a sign-in: a string added to
316
+ // the JS enum later would otherwise break every already-shipped native binary.
317
+ else -> Prompt.WHEN_REQUIRED
318
+ }
319
+
320
+ /** MSAL's outcome onto one of the stable codes in SPEC §13.6. */
321
+ private fun map(e: Throwable): Pair<String, String> = when (e) {
322
+ is MsalUiRequiredException -> RNIntuneModule.ERR_INTERACTION_REQUIRED to
323
+ "A prompt is required. Call signIn instead of signInSilent."
324
+ is MsalUserCancelException -> RNIntuneModule.ERR_USER_CANCELLED to
325
+ "The user dismissed the sign-in UI."
326
+ // Everything else keeps MSAL's own message, which names the cause better than a
327
+ // re-worded guess would. No token or claim is ever part of it.
328
+ else -> RNIntuneModule.ERR_NATIVE to (e.message ?: "MSAL failed with no description.")
329
+ }
330
+ }
@@ -0,0 +1,195 @@
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
+ import com.microsoft.intune.mam.policy.MAMServiceAuthenticationCallbackExtended
8
+ import java.util.concurrent.ConcurrentHashMap
9
+ import java.util.concurrent.CountDownLatch
10
+ import java.util.concurrent.TimeUnit
11
+ import java.util.concurrent.atomic.AtomicLong
12
+
13
+ /**
14
+ * Supplies the MAM service token (SPEC §13.4, §6.1.2).
15
+ *
16
+ * **The consumer registers this from `Application.onMAMCreate()`,** not from JS. The SDK
17
+ * can ask for a token before any React context exists — it has its own enrollment retry
18
+ * schedule that survives restarts — so registration has to happen earlier than JS is
19
+ * able to run. The module supplies the class; the wiring is one line in the host app:
20
+ *
21
+ * ```kotlin
22
+ * class MainApplication : MAMApplication() {
23
+ * override fun onMAMCreate() {
24
+ * super.onMAMCreate()
25
+ * RNIntuneAuthCallback.register(this)
26
+ * }
27
+ * }
28
+ * ```
29
+ *
30
+ * `acquireToken` is called **on a background thread and must return synchronously**, so
31
+ * this blocks that thread while JS answers. That is why the timeout is not optional: the
32
+ * SDK's thread is held for its duration.
33
+ *
34
+ * The token goes straight back to the SDK. It is never returned to JS, never logged, and
35
+ * never stored (CLAUDE.md rule 9).
36
+ */
37
+ object RNIntuneAuthCallback {
38
+
39
+ /**
40
+ * The MAM service resource. Read from the SDK's own call site in `AuthCallbackUtils`
41
+ * rather than from documentation — the SDK passes this literal when it asks the app
42
+ * for a token, so a provider building an MSAL scope needs exactly this value.
43
+ */
44
+ const val MAM_SERVICE_RESOURCE = "https://msmamservice.api.application"
45
+
46
+ /**
47
+ * How long the SDK's background thread is held while JS answers. Matches the iOS
48
+ * timeout so the two platforms fail at the same point, but here it costs a blocked
49
+ * thread rather than just a pending completion.
50
+ */
51
+ private const val TOKEN_TIMEOUT_SECONDS = 45L
52
+
53
+ private const val TAG = "RNIntuneAuth"
54
+
55
+ private val pending = ConcurrentHashMap<String, PendingToken>()
56
+ private val counter = AtomicLong(0)
57
+
58
+ @Volatile private var module: RNIntuneModule? = null
59
+
60
+ private class PendingToken {
61
+ val latch = CountDownLatch(1)
62
+
63
+ @Volatile var token: String? = null
64
+
65
+ @Volatile var failureReason: String? = null
66
+ }
67
+
68
+ /**
69
+ * Call from `Application.onMAMCreate()`. Safe to call more than once.
70
+ *
71
+ * Note what this signature does *not* mention: no MAM SDK type appears anywhere in
72
+ * this object's public API. The library depends on the vendored AAR with
73
+ * `implementation` scope, so the SDK is on the consumer's runtime classpath but not
74
+ * their compile classpath — exposing `MAMServiceAuthenticationCallbackExtended` here
75
+ * would force every consumer to declare the SDK themselves just to write one line in
76
+ * `onMAMCreate`. The conformance lives in a private class instead.
77
+ */
78
+ @JvmStatic
79
+ fun register(context: Context) {
80
+ val manager = MAMComponents.get(MAMEnrollmentManager::class.java)
81
+ if (manager == null) {
82
+ // Almost always means the MAM Gradle plugin was not applied to the app module.
83
+ Log.e(
84
+ TAG,
85
+ "MAMEnrollmentManager is unavailable, so the auth callback was not registered. " +
86
+ "This app was probably built without the MAM Gradle plugin — see the README " +
87
+ "integration checklist.",
88
+ )
89
+ return
90
+ }
91
+ manager.registerAuthenticationCallback(Conformance)
92
+ Log.i(TAG, "MAM service auth callback registered")
93
+ }
94
+
95
+ /** The SDK-facing half, kept off the public surface. */
96
+ private object Conformance : MAMServiceAuthenticationCallbackExtended {
97
+ override fun acquireToken(
98
+ upn: String,
99
+ aadId: String,
100
+ tenantId: String?,
101
+ authority: String?,
102
+ resourceId: String,
103
+ ): String? = RNIntuneAuthCallback.acquireToken(upn, aadId, tenantId, authority, resourceId)
104
+ }
105
+
106
+ internal fun attach(module: RNIntuneModule) {
107
+ this.module = module
108
+ }
109
+
110
+ internal fun detach(module: RNIntuneModule) {
111
+ if (this.module === module) this.module = null
112
+ // Nothing is going to answer any more; release every held SDK thread rather than
113
+ // leaving them blocked until the timeout.
114
+ pending.keys.toList().forEach { reject(it, "The React context went away.") }
115
+ }
116
+
117
+ /**
118
+ * Parameter order verified from the SDK's own call site
119
+ * (`AuthCallbackUtils.acquireMAMServiceToken`), which passes
120
+ * `rawUPN, aadId, tenantId, authority, "https://msmamservice.api.application"`.
121
+ *
122
+ * Worth stating because the obvious guess is wrong: `resourceId` is **last**, not
123
+ * third, and the three-argument default overload forwards `(a, b, null, null, c)`. A
124
+ * transposed version compiles cleanly and sends the resource ID as the tenant.
125
+ */
126
+ internal fun acquireToken(
127
+ @Suppress("UNUSED_PARAMETER") upn: String,
128
+ aadId: String,
129
+ tenantId: String?,
130
+ authority: String?,
131
+ resourceId: String,
132
+ ): String? {
133
+ val target = module
134
+ if (target == null) {
135
+ Log.w(TAG, "Token requested before the React context was ready; cannot answer.")
136
+ return null
137
+ }
138
+
139
+ // `builtin`: the module owns MSAL, so answer from it and never involve JS. This is
140
+ // the Android counterpart of not claiming the token delegate on iOS — same rule,
141
+ // opposite mechanics, because here the callback is mandatory and the SDK always asks.
142
+ // Doing it this way also keeps the MAM token off the bridge entirely (rule 9).
143
+ val own = target.mamServiceToken(resourceId)
144
+ if (own != null) {
145
+ return own
146
+ }
147
+
148
+ // `external`, or `builtin` with nothing cached yet: fall through to the host app's
149
+ // provider. Returning null from here is the SDK's documented "cannot get a token
150
+ // right now" and maps to AUTHORIZATION_NEEDED, not a hard failure — the SDK retries.
151
+ val requestId = "tok-${counter.incrementAndGet()}"
152
+ val slot = PendingToken()
153
+ pending[requestId] = slot
154
+
155
+ target.emitTokenRequest(
156
+ requestId = requestId,
157
+ resourceId = resourceId,
158
+ accountId = aadId,
159
+ tenantId = tenantId.orEmpty(),
160
+ authority = authority.orEmpty(),
161
+ )
162
+
163
+ return try {
164
+ if (!slot.latch.await(TOKEN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
165
+ Log.w(TAG, "Token provider did not answer within ${TOKEN_TIMEOUT_SECONDS}s.")
166
+ null
167
+ } else {
168
+ // Returning null is the SDK's documented "cannot get a token right now"; it maps
169
+ // to AUTHORIZATION_NEEDED rather than a hard failure.
170
+ slot.failureReason?.let { Log.w(TAG, "Token provider declined: $it") }
171
+ slot.token
172
+ }
173
+ } catch (e: InterruptedException) {
174
+ Thread.currentThread().interrupt()
175
+ null
176
+ } finally {
177
+ pending.remove(requestId)
178
+ }
179
+ }
180
+
181
+ internal fun resolve(requestId: String, token: String) {
182
+ // Removed rather than read: a late answer after the timeout must not be applied.
183
+ pending.remove(requestId)?.let {
184
+ it.token = token
185
+ it.latch.countDown()
186
+ }
187
+ }
188
+
189
+ internal fun reject(requestId: String, reason: String) {
190
+ pending.remove(requestId)?.let {
191
+ it.failureReason = reason
192
+ it.latch.countDown()
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,63 @@
1
+ package com.reactnativeintune
2
+
3
+ import com.facebook.react.bridge.ReadableMap
4
+
5
+ /**
6
+ * The configuration `configure()` was called with.
7
+ *
8
+ * Every field is required: `index.ts` resolves every optional to a concrete value before
9
+ * it crosses the bridge, so nothing here has to guess a default and the two platforms
10
+ * cannot drift apart on what the default was.
11
+ *
12
+ * Unlike iOS there is no runtime override to set — these values are held and passed
13
+ * per-call to `registerAccountForMAM` (SPEC §13.1).
14
+ */
15
+ internal data class RNIntuneConfig(
16
+ val clientId: String,
17
+ val tenantId: String,
18
+ val authority: String,
19
+ val redirectUri: String,
20
+ val authMode: String,
21
+ val verboseLogging: Boolean,
22
+ val strictMode: Boolean,
23
+ val telemetryEnabled: Boolean,
24
+ ) {
25
+ /**
26
+ * `builtin` means the module owns MSAL and `signIn()` works; `external` means the host
27
+ * app does and every auth method rejects with `E_EXTERNAL_AUTH_MODE` (SPEC §3.2).
28
+ *
29
+ * An unrecognised value is treated as `builtin`, matching the public default — a typo
30
+ * must not silently disable the sign-in the app is relying on.
31
+ */
32
+ val builtinAuth: Boolean
33
+ get() = authMode != "external"
34
+
35
+ companion object {
36
+ fun from(map: ReadableMap): RNIntuneConfig =
37
+ RNIntuneConfig(
38
+ clientId = map.getString(CLIENT_ID).orEmpty(),
39
+ tenantId = map.getString(TENANT_ID).orEmpty(),
40
+ authority = map.getString(AUTHORITY).orEmpty(),
41
+ redirectUri = map.getString(REDIRECT_URI).orEmpty(),
42
+ authMode = map.getString(AUTH_MODE).orEmpty(),
43
+ verboseLogging = map.getBoolean(VERBOSE_LOGGING),
44
+ strictMode = map.getBoolean(STRICT_MODE),
45
+ telemetryEnabled = map.getBoolean(TELEMETRY_ENABLED),
46
+ )
47
+
48
+ // Spec field names in one place, because a typo'd ReadableMap key reads as null and
49
+ // says nothing. These must match the config object in src/NativeIntune.ts.
50
+ private const val CLIENT_ID = "clientId"
51
+ private const val TENANT_ID = "tenantId"
52
+ private const val AUTHORITY = "authority"
53
+ private const val REDIRECT_URI = "redirectUri"
54
+ private const val AUTH_MODE = "authMode"
55
+ private const val VERBOSE_LOGGING = "verboseLogging"
56
+ private const val STRICT_MODE = "strictMode"
57
+ private const val TELEMETRY_ENABLED = "telemetryEnabled"
58
+
59
+ // Branding is iOS-only: the Android SDK styles its own screens from the app theme
60
+ // and has no runtime colour API, so those fields are read by nobody here rather
61
+ // than being faked.
62
+ }
63
+ }
@@ -0,0 +1,50 @@
1
+ package com.reactnativeintune
2
+
3
+ import com.facebook.react.bridge.ReactApplicationContext
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.modules.core.DeviceEventManagerModule
6
+
7
+ /**
8
+ * Events on their way to JS, and the queue for the ones that arrive too early.
9
+ *
10
+ * The emitter is live from module construction rather than from the first `enroll()`,
11
+ * because a service-initiated wipe can arrive with no prior app call at all. Anything
12
+ * emitted before JS subscribes is held rather than dropped: a wipe notification lost
13
+ * during startup means the reset machine never runs (SPEC §4.4).
14
+ */
15
+ internal class RNIntuneEventQueue(private val context: ReactApplicationContext) {
16
+
17
+ private val pending = mutableListOf<Pair<String, WritableMap>>()
18
+ private var listenerCount = 0
19
+
20
+ /** Emits now if JS is listening, queues otherwise. */
21
+ fun emit(event: String, payload: WritableMap) {
22
+ synchronized(this) {
23
+ if (listenerCount == 0) {
24
+ pending += event to payload
25
+ return
26
+ }
27
+ }
28
+ send(event, payload)
29
+ }
30
+
31
+ fun addListener() {
32
+ val queued =
33
+ synchronized(this) {
34
+ listenerCount += 1
35
+ if (listenerCount != 1) return
36
+ pending.toList().also { pending.clear() }
37
+ }
38
+ queued.forEach { (event, payload) -> send(event, payload) }
39
+ }
40
+
41
+ fun removeListeners(count: Int) {
42
+ synchronized(this) { listenerCount = (listenerCount - count).coerceAtLeast(0) }
43
+ }
44
+
45
+ private fun send(event: String, payload: WritableMap) {
46
+ context
47
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
48
+ .emit(event, payload)
49
+ }
50
+ }