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,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One list of checks, two front-ends.
|
|
3
|
+
*
|
|
4
|
+
* `doctor` reports them and changes nothing; `setup` applies the ones that carry an
|
|
5
|
+
* `apply`. Keeping them in one place is what stops the two commands from disagreeing —
|
|
6
|
+
* a `doctor` that passes while `setup` still wants to change something is worse than
|
|
7
|
+
* either alone.
|
|
8
|
+
*
|
|
9
|
+
* Severity is the risk model from SPEC §12.7, not a politeness scale:
|
|
10
|
+
*
|
|
11
|
+
* - `silent` — omitting it builds, runs, and looks correct while leaving the app
|
|
12
|
+
* unprotected. For software a customer bought for security reasons this is
|
|
13
|
+
* the worst outcome, because everyone believes protection is in place.
|
|
14
|
+
* - `loud` — the build breaks, or the first sign-in fails. Costs an hour, not a
|
|
15
|
+
* breach. Most of these were found on real devices, and what makes them
|
|
16
|
+
* expensive is that the error names the wrong cause.
|
|
17
|
+
* - `advisory` — worth knowing, not blocking.
|
|
18
|
+
*
|
|
19
|
+
* `state` is one of: `ok`, `missing`, `wrong`, `unknown`, `skip`. `unknown` is used
|
|
20
|
+
* deliberately rather than guessed — a check that cannot see what it needs says so,
|
|
21
|
+
* because a green result it did not earn is the one outcome this tool must never
|
|
22
|
+
* produce.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import { read, rel } from './project.mjs';
|
|
28
|
+
|
|
29
|
+
const MARKER = 'react-native-intune: managed block';
|
|
30
|
+
|
|
31
|
+
export const BROKER_PACKAGES = [
|
|
32
|
+
'com.microsoft.windowsintune.companyportal',
|
|
33
|
+
'com.azure.authenticator',
|
|
34
|
+
'com.microsoft.workaccount',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const MSAL_QUERY_SCHEMES = ['msauthv2', 'msauthv3'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The two groups whose names are fixed. The app's own group is the third requirement and
|
|
41
|
+
* is checked separately, because its name is the app's and may be written either as
|
|
42
|
+
* `$(PRODUCT_BUNDLE_IDENTIFIER)` or as the literal bundle id — both are valid, and
|
|
43
|
+
* demanding one spelling reports a correctly configured project as broken.
|
|
44
|
+
*/
|
|
45
|
+
export const KEYCHAIN_GROUPS = [
|
|
46
|
+
'$(AppIdentifierPrefix)com.microsoft.intune.mam',
|
|
47
|
+
'$(AppIdentifierPrefix)com.microsoft.adalcache',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------- android
|
|
51
|
+
|
|
52
|
+
const androidChecks = [
|
|
53
|
+
{
|
|
54
|
+
id: 'android-mam-plugin',
|
|
55
|
+
platform: 'android',
|
|
56
|
+
title: 'MAM Gradle plugin applied to the app module',
|
|
57
|
+
severity: 'silent',
|
|
58
|
+
inspect(project) {
|
|
59
|
+
const a = project.android;
|
|
60
|
+
if (a.appBuildGradleKts && !a.appBuildGradle) {
|
|
61
|
+
return {
|
|
62
|
+
state: 'unknown',
|
|
63
|
+
detail:
|
|
64
|
+
'app/build.gradle.kts — the Kotlin DSL is not inspected. Check by hand ' +
|
|
65
|
+
'that the plugin is applied.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const src = read(a.appBuildGradle);
|
|
69
|
+
if (!src) {
|
|
70
|
+
return { state: 'unknown', detail: 'android/app/build.gradle not found' };
|
|
71
|
+
}
|
|
72
|
+
return /com\.microsoft\.intune\.mam/.test(src)
|
|
73
|
+
? { state: 'ok' }
|
|
74
|
+
: { state: 'missing' };
|
|
75
|
+
},
|
|
76
|
+
why:
|
|
77
|
+
'The plugin rewrites bytecode across the app and every dependency. Applied to ' +
|
|
78
|
+
'the library instead of the app module it rewrites only the library, builds ' +
|
|
79
|
+
'cleanly, and protects nothing.',
|
|
80
|
+
instruction: () =>
|
|
81
|
+
`In android/app/build.gradle, after the other plugins:
|
|
82
|
+
|
|
83
|
+
apply plugin: "com.microsoft.intune.mam"
|
|
84
|
+
|
|
85
|
+
intunemam {
|
|
86
|
+
report = true // an HTML report of every replacement
|
|
87
|
+
verify = true // catches plugin-induced runtime failures
|
|
88
|
+
incremental = true // the default is false
|
|
89
|
+
}`,
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
id: 'android-mam-plugin-classpath',
|
|
94
|
+
platform: 'android',
|
|
95
|
+
title: 'MAM Gradle plugin on the buildscript classpath',
|
|
96
|
+
severity: 'loud',
|
|
97
|
+
inspect(project) {
|
|
98
|
+
const src = read(project.android.rootBuildGradle);
|
|
99
|
+
if (!src) {
|
|
100
|
+
return { state: 'unknown', detail: 'android/build.gradle not found' };
|
|
101
|
+
}
|
|
102
|
+
return /com\.microsoft\.intune\.mam\.build/.test(src)
|
|
103
|
+
? { state: 'ok' }
|
|
104
|
+
: { state: 'missing' };
|
|
105
|
+
},
|
|
106
|
+
why: 'Without it the plugin id cannot resolve and the build fails immediately.',
|
|
107
|
+
instruction: () =>
|
|
108
|
+
`In android/build.gradle, inside buildscript { dependencies { } }:
|
|
109
|
+
|
|
110
|
+
classpath files("$rootDir/../node_modules/react-native-intune/vendor/android/GradlePlugin/com.microsoft.intune.mam.build.jar")
|
|
111
|
+
|
|
112
|
+
And javassist, whose version must match the SDK exactly:
|
|
113
|
+
|
|
114
|
+
classpath "org.javassist:javassist:3.29.2-GA"`,
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
id: 'android-broker-queries',
|
|
119
|
+
platform: 'android',
|
|
120
|
+
title: '<queries> for the broker apps',
|
|
121
|
+
severity: 'loud',
|
|
122
|
+
inspect(project) {
|
|
123
|
+
const src = read(project.android.manifest);
|
|
124
|
+
if (!src) {
|
|
125
|
+
return { state: 'unknown', detail: 'AndroidManifest.xml not found' };
|
|
126
|
+
}
|
|
127
|
+
const missing = BROKER_PACKAGES.filter((p) => !src.includes(p));
|
|
128
|
+
if (missing.length === 0) {
|
|
129
|
+
return { state: 'ok' };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
state: src.includes('<queries>') ? 'wrong' : 'missing',
|
|
133
|
+
detail: `missing: ${missing.join(', ')}`,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
why:
|
|
137
|
+
'Mandatory on Android 11+. Without it an installed broker is invisible to the ' +
|
|
138
|
+
'app, so MSAL silently falls back to a browser and brokered auth "just does not ' +
|
|
139
|
+
'work" with no error.',
|
|
140
|
+
instruction: () =>
|
|
141
|
+
`In android/app/src/main/AndroidManifest.xml, as a direct child of <manifest>:
|
|
142
|
+
|
|
143
|
+
<queries>
|
|
144
|
+
${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n')}
|
|
145
|
+
</queries>`,
|
|
146
|
+
apply(project) {
|
|
147
|
+
const file = project.android.manifest;
|
|
148
|
+
const src = read(file);
|
|
149
|
+
if (!src) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const block = ` <!-- ${MARKER} -->
|
|
153
|
+
<queries>
|
|
154
|
+
${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n')}
|
|
155
|
+
</queries>
|
|
156
|
+
`;
|
|
157
|
+
let next;
|
|
158
|
+
if (src.includes('<queries>')) {
|
|
159
|
+
// Merge into the existing block rather than adding a second one — two <queries>
|
|
160
|
+
// elements are legal but make the next reader wonder which one wins.
|
|
161
|
+
next = src.replace(
|
|
162
|
+
/<queries>/,
|
|
163
|
+
`<queries>\n${BROKER_PACKAGES.filter((p) => !src.includes(p))
|
|
164
|
+
.map((p) => ` <package android:name="${p}" />`)
|
|
165
|
+
.join('\n')}`
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
next = src.replace(/(<manifest[^>]*>\n)/, `$1\n${block}`);
|
|
169
|
+
}
|
|
170
|
+
return next === src ? null : { file, contents: next };
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
id: 'android-mam-application',
|
|
176
|
+
platform: 'android',
|
|
177
|
+
title: 'Application class derives from MAMApplication',
|
|
178
|
+
severity: 'silent',
|
|
179
|
+
inspect(project) {
|
|
180
|
+
const sources = project.android.applicationSources;
|
|
181
|
+
if (sources.length === 0) {
|
|
182
|
+
return {
|
|
183
|
+
state: 'missing',
|
|
184
|
+
detail: 'no Application subclass found in android/app/src/main',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const hit = sources.find((f) => /MAMApplication/.test(read(f)));
|
|
188
|
+
if (hit) {
|
|
189
|
+
return { state: 'ok', detail: rel(project, hit) };
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
state: 'wrong',
|
|
193
|
+
detail: `found ${sources.map((f) => rel(project, f)).join(', ')} — none extends MAMApplication`,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
why:
|
|
197
|
+
'One of the three omissions that build and run while protecting nothing. The ' +
|
|
198
|
+
'plugin transforms an Application subclass; with none to transform, or one that ' +
|
|
199
|
+
'does not derive from MAMApplication, the SDK is present and inert.',
|
|
200
|
+
instruction: () =>
|
|
201
|
+
`Your Application class must extend MAMApplication, and the auth callback is
|
|
202
|
+
registered from onMAMCreate — the plugin rewrites onCreate into it:
|
|
203
|
+
|
|
204
|
+
class MainApplication : MAMApplication(), ReactApplication {
|
|
205
|
+
override fun onMAMCreate() {
|
|
206
|
+
super.onMAMCreate()
|
|
207
|
+
RNIntuneAuthCallback.register(this)
|
|
208
|
+
// ... the rest of your existing onCreate body
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
This is a change to your own source, so setup reports it rather than editing it.`,
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
id: 'android-auth-callback',
|
|
217
|
+
platform: 'android',
|
|
218
|
+
title: 'MAM auth callback registered',
|
|
219
|
+
severity: 'silent',
|
|
220
|
+
inspect(project) {
|
|
221
|
+
const sources = project.android.applicationSources;
|
|
222
|
+
if (sources.length === 0) {
|
|
223
|
+
return { state: 'missing', detail: 'no Application subclass found' };
|
|
224
|
+
}
|
|
225
|
+
return sources.some((f) => /RNIntuneAuthCallback\s*\.\s*register/.test(read(f)))
|
|
226
|
+
? { state: 'ok' }
|
|
227
|
+
: { state: 'missing' };
|
|
228
|
+
},
|
|
229
|
+
why:
|
|
230
|
+
'The SDK asks for a MAM service token on its own schedule, before any JS exists. ' +
|
|
231
|
+
'Without the callback registered, enrollment reports AUTHORIZATION_NEEDED with ' +
|
|
232
|
+
'APP_DID_NOT_PROVIDE_TOKEN — which reads like a licensing problem, and retries ' +
|
|
233
|
+
'on a backoff so it never gets louder.',
|
|
234
|
+
instruction: () =>
|
|
235
|
+
`In your Application's onMAMCreate, after super:
|
|
236
|
+
|
|
237
|
+
RNIntuneAuthCallback.register(this)
|
|
238
|
+
|
|
239
|
+
Code inside your own class, so setup reports it rather than editing it.`,
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
id: 'android-browser-tab-activity',
|
|
244
|
+
platform: 'android',
|
|
245
|
+
title: 'BrowserTabActivity for the MSAL redirect',
|
|
246
|
+
severity: 'loud',
|
|
247
|
+
inspect(project) {
|
|
248
|
+
const src = read(project.android.manifest);
|
|
249
|
+
if (!src) {
|
|
250
|
+
return { state: 'unknown', detail: 'AndroidManifest.xml not found' };
|
|
251
|
+
}
|
|
252
|
+
return /BrowserTabActivity/.test(src) ? { state: 'ok' } : { state: 'missing' };
|
|
253
|
+
},
|
|
254
|
+
why:
|
|
255
|
+
'MSAL needs it to receive the sign-in redirect. Absent, sign-in loops back to ' +
|
|
256
|
+
'the login screen with nothing in the logs naming the cause.',
|
|
257
|
+
instruction(project) {
|
|
258
|
+
const hash = signatureHash(project);
|
|
259
|
+
const shown = hash ?? '<your signature hash>';
|
|
260
|
+
return `In AndroidManifest.xml, inside <application>:
|
|
261
|
+
|
|
262
|
+
<activity
|
|
263
|
+
android:name="com.microsoft.identity.client.BrowserTabActivity"
|
|
264
|
+
android:exported="true">
|
|
265
|
+
<intent-filter>
|
|
266
|
+
<action android:name="android.intent.action.VIEW" />
|
|
267
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
268
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
269
|
+
<data
|
|
270
|
+
android:scheme="msauth"
|
|
271
|
+
android:host="<your.package.name>"
|
|
272
|
+
android:path="/${shown}" />
|
|
273
|
+
</intent-filter>
|
|
274
|
+
</activity>
|
|
275
|
+
|
|
276
|
+
Two details that each cost a build cycle:
|
|
277
|
+
|
|
278
|
+
- android:path takes the DECODED hash — "/" and "=" literal — while the redirect URI
|
|
279
|
+
registered in Entra percent-encodes them as %2F and %3D. Same value, written two
|
|
280
|
+
ways, in two places that must agree.
|
|
281
|
+
- The hash must be of the key that ACTUALLY signs the build.${
|
|
282
|
+
hash
|
|
283
|
+
? `\n\n Computed from android/app/debug.keystore just now: ${hash}`
|
|
284
|
+
: `\n\n Could not compute it here — keytool or openssl is unavailable. Derive it with:
|
|
285
|
+
|
|
286
|
+
keytool -exportcert -alias androiddebugkey \\
|
|
287
|
+
-keystore android/app/debug.keystore -storepass android \\
|
|
288
|
+
| openssl sha1 -binary | openssl base64`
|
|
289
|
+
}`;
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
{
|
|
294
|
+
id: 'android-toolchain',
|
|
295
|
+
platform: 'android',
|
|
296
|
+
title: 'App toolchain on the MAM compatibility matrix row',
|
|
297
|
+
/**
|
|
298
|
+
* Advisory, and deliberately so. Mixing matrix rows is *untested* by Microsoft, not
|
|
299
|
+
* known-broken — plenty of combinations work. But when one does not, the failure lands
|
|
300
|
+
* inside javassist's bytecode rewriting and names a class in the app rather than a
|
|
301
|
+
* version anywhere, so knowing the row was mixed is worth the twenty lines it takes
|
|
302
|
+
* to say it.
|
|
303
|
+
*
|
|
304
|
+
* This is what the open question about the host app's toolchain reduces to for a
|
|
305
|
+
* library: there is no single host app to inspect, so the row is published and every
|
|
306
|
+
* consumer is measured against it.
|
|
307
|
+
*/
|
|
308
|
+
severity: 'advisory',
|
|
309
|
+
inspect(project) {
|
|
310
|
+
const root = read(project.android.rootBuildGradle);
|
|
311
|
+
const kts = read(project.android.rootBuildGradleKts);
|
|
312
|
+
const src = root || kts;
|
|
313
|
+
if (!src) {
|
|
314
|
+
return { state: 'unknown', detail: 'android/build.gradle not found' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (Object.keys(REQUIRED_TOOLCHAIN).length === 0) {
|
|
318
|
+
return {
|
|
319
|
+
state: 'unknown',
|
|
320
|
+
detail: "this library's own sdk-versions.json could not be read",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const found = {
|
|
325
|
+
agp: firstMatch(src, /com\.android\.tools\.build:gradle:([\d.]+)/),
|
|
326
|
+
kotlin: firstMatch(src, /kotlin(?:-gradle-plugin|\("jvm"\))?[:"' ]+version[:"' ]+([\d.]+)/i) ||
|
|
327
|
+
firstMatch(src, /kotlin-gradle-plugin:([\d.]+)/) ||
|
|
328
|
+
firstMatch(src, /kotlinVersion["' ]*[:=]["' ]*([\d.]+)/),
|
|
329
|
+
gradle: gradleWrapperVersion(project),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const mismatched = Object.entries(REQUIRED_TOOLCHAIN)
|
|
333
|
+
.filter(([k]) => found[k])
|
|
334
|
+
.filter(([k, want]) => found[k] !== want)
|
|
335
|
+
.map(([k, want]) => `${k} ${found[k]} vs ${want}`);
|
|
336
|
+
|
|
337
|
+
const unseen = Object.keys(REQUIRED_TOOLCHAIN).filter((k) => !found[k]);
|
|
338
|
+
|
|
339
|
+
if (mismatched.length > 0) {
|
|
340
|
+
return { state: 'wrong', detail: mismatched.join(', ') };
|
|
341
|
+
}
|
|
342
|
+
if (unseen.length === Object.keys(REQUIRED_TOOLCHAIN).length) {
|
|
343
|
+
return {
|
|
344
|
+
state: 'unknown',
|
|
345
|
+
detail: 'could not read any version from the build files',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
state: 'ok',
|
|
350
|
+
detail: unseen.length > 0 ? `${unseen.join(', ')} not read` : undefined,
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
why:
|
|
354
|
+
'Microsoft tests specific combinations of Gradle, AGP and Kotlin against each MAM ' +
|
|
355
|
+
'SDK release. A mixed row is untested rather than broken — but the MAM plugin ' +
|
|
356
|
+
'rewrites bytecode in your app module, so when a mixed row does fail it fails ' +
|
|
357
|
+
'inside javassist and the error names one of your classes, never a version.',
|
|
358
|
+
instruction: () =>
|
|
359
|
+
`The row this library is pinned to:
|
|
360
|
+
|
|
361
|
+
Gradle ${REQUIRED_TOOLCHAIN.gradle}
|
|
362
|
+
AGP ${REQUIRED_TOOLCHAIN.agp}
|
|
363
|
+
Kotlin ${REQUIRED_TOOLCHAIN.kotlin}
|
|
364
|
+
Java 17
|
|
365
|
+
|
|
366
|
+
Matching it exactly is the tested path. If your app cannot move, the alternative is
|
|
367
|
+
pinning an older MAM SDK whose row you do match — see sdk-versions.json.`,
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------- ios
|
|
372
|
+
|
|
373
|
+
const iosChecks = [
|
|
374
|
+
{
|
|
375
|
+
id: 'ios-keychain-groups',
|
|
376
|
+
platform: 'ios',
|
|
377
|
+
title: 'Keychain sharing groups, in order',
|
|
378
|
+
severity: 'loud',
|
|
379
|
+
inspect(project) {
|
|
380
|
+
const file = project.ios.entitlements;
|
|
381
|
+
if (!file) {
|
|
382
|
+
return { state: 'missing', detail: 'no .entitlements file found' };
|
|
383
|
+
}
|
|
384
|
+
// Only the array's own <string> entries, not the whole file: an explanatory
|
|
385
|
+
// comment mentioning a group name would otherwise satisfy the check.
|
|
386
|
+
const entries = keychainEntries(read(file));
|
|
387
|
+
if (entries === null) {
|
|
388
|
+
return { state: 'missing', detail: 'no keychain-access-groups array' };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const missing = KEYCHAIN_GROUPS.filter((g) => !entries.includes(g));
|
|
392
|
+
if (missing.length > 0) {
|
|
393
|
+
return { state: 'wrong', detail: `missing: ${missing.join(', ')}` };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The app's own group, in either accepted spelling.
|
|
397
|
+
const bundleId = bundleIdentifier(project);
|
|
398
|
+
const ownGroup = entries.findIndex(
|
|
399
|
+
(e) =>
|
|
400
|
+
e === '$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)' ||
|
|
401
|
+
(bundleId && e === `$(AppIdentifierPrefix)${bundleId}`)
|
|
402
|
+
);
|
|
403
|
+
if (ownGroup === -1) {
|
|
404
|
+
return {
|
|
405
|
+
state: 'wrong',
|
|
406
|
+
detail: bundleId
|
|
407
|
+
? `the app's own group is absent — expected $(AppIdentifierPrefix)${bundleId} or the $(PRODUCT_BUNDLE_IDENTIFIER) form`
|
|
408
|
+
: "the app's own group is absent, and the bundle id could not be read to name it",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (ownGroup !== 0) {
|
|
412
|
+
return { state: 'wrong', detail: "the app's own group must be first" };
|
|
413
|
+
}
|
|
414
|
+
return { state: 'ok' };
|
|
415
|
+
},
|
|
416
|
+
why:
|
|
417
|
+
'Without them the keychain returns -34018 (errSecMissingEntitlement), which the ' +
|
|
418
|
+
'SDK surfaces as an authorization failure — an error naming neither the keychain ' +
|
|
419
|
+
'nor the entitlement. Found this way on a device.',
|
|
420
|
+
instruction: (project) => {
|
|
421
|
+
const own = bundleIdentifier(project)
|
|
422
|
+
? `$(AppIdentifierPrefix)${bundleIdentifier(project)}`
|
|
423
|
+
: '$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)';
|
|
424
|
+
return `In your app's .entitlements, keychain-access-groups in exactly this order:
|
|
425
|
+
|
|
426
|
+
<key>keychain-access-groups</key>
|
|
427
|
+
<array>
|
|
428
|
+
<string>${own}</string>
|
|
429
|
+
${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
|
|
430
|
+
</array>
|
|
431
|
+
|
|
432
|
+
The app's own group first, then the SDK's, then MSAL's cache. The first entry may be
|
|
433
|
+
written as the literal bundle id or as $(PRODUCT_BUNDLE_IDENTIFIER) — both work.`;
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
{
|
|
438
|
+
id: 'ios-url-types',
|
|
439
|
+
platform: 'ios',
|
|
440
|
+
title: 'CFBundleURLTypes for the MSAL redirect',
|
|
441
|
+
severity: 'loud',
|
|
442
|
+
inspect(project) {
|
|
443
|
+
const src = read(project.ios.infoPlist);
|
|
444
|
+
if (!src) {
|
|
445
|
+
return { state: 'unknown', detail: 'Info.plist not found' };
|
|
446
|
+
}
|
|
447
|
+
if (!src.includes('CFBundleURLTypes')) {
|
|
448
|
+
return { state: 'missing' };
|
|
449
|
+
}
|
|
450
|
+
return /msauth\./.test(src)
|
|
451
|
+
? { state: 'ok' }
|
|
452
|
+
: { state: 'wrong', detail: 'CFBundleURLTypes present but no msauth. scheme' };
|
|
453
|
+
},
|
|
454
|
+
why:
|
|
455
|
+
'Without it the sign-in redirect never comes back and the login screen reopens ' +
|
|
456
|
+
'forever — a silent loop with no error at all. Found this way on a device.',
|
|
457
|
+
instruction: () =>
|
|
458
|
+
`In Info.plist:
|
|
459
|
+
|
|
460
|
+
<key>CFBundleURLTypes</key>
|
|
461
|
+
<array>
|
|
462
|
+
<dict>
|
|
463
|
+
<key>CFBundleURLSchemes</key>
|
|
464
|
+
<array>
|
|
465
|
+
<string>msauth.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
466
|
+
</array>
|
|
467
|
+
</dict>
|
|
468
|
+
</array>`,
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
{
|
|
472
|
+
id: 'ios-query-schemes',
|
|
473
|
+
platform: 'ios',
|
|
474
|
+
title: 'LSApplicationQueriesSchemes for the brokers',
|
|
475
|
+
severity: 'loud',
|
|
476
|
+
inspect(project) {
|
|
477
|
+
const src = read(project.ios.infoPlist);
|
|
478
|
+
if (!src) {
|
|
479
|
+
return { state: 'unknown', detail: 'Info.plist not found' };
|
|
480
|
+
}
|
|
481
|
+
const missing = MSAL_QUERY_SCHEMES.filter((s) => !src.includes(`<string>${s}<`));
|
|
482
|
+
return missing.length === 0
|
|
483
|
+
? { state: 'ok' }
|
|
484
|
+
: { state: 'missing', detail: `missing: ${missing.join(', ')}` };
|
|
485
|
+
},
|
|
486
|
+
why:
|
|
487
|
+
'MSAL queries msauthv2://broker to decide whether a broker is present. Without ' +
|
|
488
|
+
'the scheme declared, canOpenURL answers no for a broker that is installed, and ' +
|
|
489
|
+
'the app tells users to install what they already have.',
|
|
490
|
+
instruction: () =>
|
|
491
|
+
`In Info.plist:
|
|
492
|
+
|
|
493
|
+
<key>LSApplicationQueriesSchemes</key>
|
|
494
|
+
<array>
|
|
495
|
+
${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
|
|
496
|
+
<string>companyportal</string>
|
|
497
|
+
</array>`,
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
{
|
|
501
|
+
id: 'ios-no-adal-keys',
|
|
502
|
+
platform: 'ios',
|
|
503
|
+
title: 'No ADAL* identity keys in Info.plist',
|
|
504
|
+
severity: 'silent',
|
|
505
|
+
inspect(project) {
|
|
506
|
+
const src = read(project.ios.infoPlist);
|
|
507
|
+
if (!src) {
|
|
508
|
+
return { state: 'unknown', detail: 'Info.plist not found' };
|
|
509
|
+
}
|
|
510
|
+
const found = ['ADALClientId', 'ADALAuthority', 'ADALRedirectUri'].filter((k) =>
|
|
511
|
+
src.includes(k)
|
|
512
|
+
);
|
|
513
|
+
return found.length === 0
|
|
514
|
+
? { state: 'ok' }
|
|
515
|
+
: { state: 'wrong', detail: `present: ${found.join(', ')}` };
|
|
516
|
+
},
|
|
517
|
+
why:
|
|
518
|
+
'This module configures identity at runtime. A plist key alongside a runtime ' +
|
|
519
|
+
'override is a known cause of enrollment failures, and the failure names the ' +
|
|
520
|
+
'authority rather than the conflict. configure() refuses to start when it sees ' +
|
|
521
|
+
'one, so this is caught — but it is cheaper to find here.',
|
|
522
|
+
instruction: () =>
|
|
523
|
+
`Remove ADALClientId, ADALAuthority and ADALRedirectUri from
|
|
524
|
+
Info.plist -> IntuneMAMSettings. Identity comes from configure() at runtime.
|
|
525
|
+
|
|
526
|
+
Note: ADALCacheKeychainGroupOverride is a different key and is NOT removed — it has
|
|
527
|
+
no runtime equivalent and is the only way to set the keychain group.`,
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
{
|
|
531
|
+
id: 'ios-msal-response-handler',
|
|
532
|
+
platform: 'ios',
|
|
533
|
+
title: 'AppDelegate forwards the MSAL redirect',
|
|
534
|
+
severity: 'loud',
|
|
535
|
+
inspect(project) {
|
|
536
|
+
const file = project.ios.appDelegate;
|
|
537
|
+
if (!file) {
|
|
538
|
+
return { state: 'unknown', detail: 'AppDelegate not found' };
|
|
539
|
+
}
|
|
540
|
+
return /handleMSALResponse/.test(read(file))
|
|
541
|
+
? { state: 'ok' }
|
|
542
|
+
: { state: 'missing', detail: rel(project, file) };
|
|
543
|
+
},
|
|
544
|
+
why:
|
|
545
|
+
'The URL type alone is not enough: without the forward, the redirect reaches the ' +
|
|
546
|
+
'app and is dropped, producing the same silent login loop. Both halves were ' +
|
|
547
|
+
'needed on a device.',
|
|
548
|
+
instruction: () =>
|
|
549
|
+
`In AppDelegate.swift:
|
|
550
|
+
|
|
551
|
+
import MSAL
|
|
552
|
+
|
|
553
|
+
func application(_ app: UIApplication, open url: URL,
|
|
554
|
+
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
|
555
|
+
MSALPublicClientApplication.handleMSALResponse(
|
|
556
|
+
url, sourceApplication: options[.sourceApplication] as? String)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
Code in your own AppDelegate, so setup reports it rather than editing it.`,
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
{
|
|
563
|
+
id: 'ios-scene-configurations',
|
|
564
|
+
platform: 'ios',
|
|
565
|
+
title: 'UISceneConfigurations is non-empty',
|
|
566
|
+
severity: 'silent',
|
|
567
|
+
inspect(project) {
|
|
568
|
+
const src = read(project.ios.infoPlist);
|
|
569
|
+
if (!src) {
|
|
570
|
+
return { state: 'unknown', detail: 'Info.plist not found' };
|
|
571
|
+
}
|
|
572
|
+
if (!src.includes('UIApplicationSceneManifest')) {
|
|
573
|
+
// No scene manifest at all is the pre-scene lifecycle, which is fine.
|
|
574
|
+
return { state: 'skip', detail: 'no scene manifest — not applicable' };
|
|
575
|
+
}
|
|
576
|
+
if (!src.includes('UISceneConfigurations')) {
|
|
577
|
+
return { state: 'wrong', detail: 'scene manifest present, configurations absent' };
|
|
578
|
+
}
|
|
579
|
+
// An empty <dict/> is the documented failure, and it is easy to miss by eye.
|
|
580
|
+
return /<key>UISceneConfigurations<\/key>\s*<dict\s*\/>/.test(src)
|
|
581
|
+
? { state: 'wrong', detail: 'UISceneConfigurations is an empty dict' }
|
|
582
|
+
: { state: 'ok' };
|
|
583
|
+
},
|
|
584
|
+
why:
|
|
585
|
+
'Microsoft documents that with an empty or missing UISceneConfigurations in a ' +
|
|
586
|
+
'SwiftUI app, policy applies successfully and the SDK still fails to protect the ' +
|
|
587
|
+
'app. Success is reported and nothing is protected.',
|
|
588
|
+
instruction: () =>
|
|
589
|
+
`Populate UIApplicationSceneManifest -> UISceneConfigurations with your scene
|
|
590
|
+
delegate rather than leaving it empty. An empty dict is the failing state.`,
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
{
|
|
594
|
+
id: 'ios-configurator-phase',
|
|
595
|
+
platform: 'ios',
|
|
596
|
+
title: 'IntuneMAMConfigurator build phase',
|
|
597
|
+
severity: 'advisory',
|
|
598
|
+
inspect(project) {
|
|
599
|
+
const src = read(project.ios.pbxproj);
|
|
600
|
+
if (!src) {
|
|
601
|
+
return { state: 'unknown', detail: 'project.pbxproj not found' };
|
|
602
|
+
}
|
|
603
|
+
return /IntuneMAMConfigurator/.test(src)
|
|
604
|
+
? { state: 'ok' }
|
|
605
|
+
: { state: 'missing' };
|
|
606
|
+
},
|
|
607
|
+
why:
|
|
608
|
+
"Microsoft's own tool writes the plist and entitlements the SDK needs. It is " +
|
|
609
|
+
'idempotent and must re-run on every SDK bump, which is why it belongs in a build ' +
|
|
610
|
+
'phase rather than in someone\'s memory.',
|
|
611
|
+
instruction: () =>
|
|
612
|
+
`Add a "Run Script" build phase to your app target, before Compile Sources:
|
|
613
|
+
|
|
614
|
+
"$SRCROOT/../node_modules/react-native-intune/vendor/ios/IntuneMAMConfigurator" \\
|
|
615
|
+
-i "$SRCROOT/$INFOPLIST_FILE" \\
|
|
616
|
+
-e "$SRCROOT/${'$'}{CODE_SIGN_ENTITLEMENTS}"
|
|
617
|
+
|
|
618
|
+
Not wrapped by setup: it is Microsoft's tool, and wrapping it means owning what it
|
|
619
|
+
does. The line above is what to paste.`,
|
|
620
|
+
},
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
export const checks = [...androidChecks, ...iosChecks];
|
|
624
|
+
|
|
625
|
+
/** The <string> entries of the keychain-access-groups array, in order, or null. */
|
|
626
|
+
function keychainEntries(src) {
|
|
627
|
+
const m = src.match(
|
|
628
|
+
/<key>keychain-access-groups<\/key>\s*<array>([\s\S]*?)<\/array>/
|
|
629
|
+
);
|
|
630
|
+
if (!m) {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
return [...m[1].matchAll(/<string>([^<]*)<\/string>/g)].map((x) => x[1].trim());
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The app's bundle identifier, from the Xcode project.
|
|
638
|
+
*
|
|
639
|
+
* Read rather than assumed so the keychain check can accept the literal spelling. The
|
|
640
|
+
* first non-variable value wins: a project defines it per configuration, and they agree
|
|
641
|
+
* in every template.
|
|
642
|
+
*/
|
|
643
|
+
function bundleIdentifier(project) {
|
|
644
|
+
const src = read(project.ios?.pbxproj);
|
|
645
|
+
for (const m of src.matchAll(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g)) {
|
|
646
|
+
const value = m[1].trim().replace(/^"|"$/g, '');
|
|
647
|
+
if (value && !value.startsWith('$')) {
|
|
648
|
+
return value;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The SHA-1 of the debug keystore, base64 — the value the MSAL redirect URI needs.
|
|
656
|
+
*
|
|
657
|
+
* Computed rather than asked for, because getting it wrong is the single most common
|
|
658
|
+
* Android setup failure and the error names neither keystore. Returns null when the
|
|
659
|
+
* tools are unavailable; the instruction then prints the command instead.
|
|
660
|
+
*
|
|
661
|
+
* Note which keystore: `android/app/debug.keystore`, the one React Native's template
|
|
662
|
+
* ships and signs debug builds with — not `~/.android/debug.keystore`. They are
|
|
663
|
+
* different keys, and using the machine's one is exactly the mistake this project made.
|
|
664
|
+
*/
|
|
665
|
+
export function signatureHash(project) {
|
|
666
|
+
const keystore = project.android?.debugKeystore;
|
|
667
|
+
if (!keystore || !fs.existsSync(keystore)) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const cert = execFileSync(
|
|
672
|
+
'keytool',
|
|
673
|
+
[
|
|
674
|
+
'-exportcert',
|
|
675
|
+
'-alias',
|
|
676
|
+
'androiddebugkey',
|
|
677
|
+
'-keystore',
|
|
678
|
+
keystore,
|
|
679
|
+
'-storepass',
|
|
680
|
+
'android',
|
|
681
|
+
],
|
|
682
|
+
{ maxBuffer: 1 << 20 }
|
|
683
|
+
);
|
|
684
|
+
const sha1 = execFileSync('openssl', ['sha1', '-binary'], { input: cert });
|
|
685
|
+
return execFileSync('openssl', ['base64'], { input: sha1 }).toString().trim();
|
|
686
|
+
} catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The compatibility-matrix row this library is pinned to, read from sdk-versions.json so
|
|
693
|
+
* there is one copy. Duplicating it here is how the two drift and the check starts
|
|
694
|
+
* reporting a row nobody targets.
|
|
695
|
+
*/
|
|
696
|
+
const REQUIRED_TOOLCHAIN = (() => {
|
|
697
|
+
try {
|
|
698
|
+
const url = new URL('../../sdk-versions.json', import.meta.url);
|
|
699
|
+
const { toolchain } = JSON.parse(fs.readFileSync(url, 'utf8'));
|
|
700
|
+
const { requires } = toolchain;
|
|
701
|
+
return { gradle: requires.gradle, agp: requires.agp, kotlin: requires.kotlin };
|
|
702
|
+
} catch {
|
|
703
|
+
return {};
|
|
704
|
+
}
|
|
705
|
+
})();
|
|
706
|
+
|
|
707
|
+
function firstMatch(src, re) {
|
|
708
|
+
const m = src.match(re);
|
|
709
|
+
return m ? m[1] : null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Gradle's version lives in the wrapper properties, not in any build file. */
|
|
713
|
+
function gradleWrapperVersion(project) {
|
|
714
|
+
const src = read(
|
|
715
|
+
`${project.android.dir}/gradle/wrapper/gradle-wrapper.properties`
|
|
716
|
+
);
|
|
717
|
+
return firstMatch(src, /gradle-([\d.]+)-(?:bin|all)\.zip/);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export { MARKER };
|