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,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
|
+
}
|