@nativelement/capacitor-msal-entra 0.0.0-stage → 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1
4
+
5
+ - Android: pin MSAL to `5.10.2` (was the floating `5.+`) for reproducible builds.
6
+
7
+ ## 1.0.0
8
+
9
+ - First public release as `@nativelement/capacitor-msal-entra`.
10
+ - BREAKING (native): Android namespace changed from `io.nativelement.demostarter` to `io.nativelement.msalentra`. The JS API (`MsalAuth`) is unchanged.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nativelement
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'NativelementCapacitorMsalEntra'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license']
10
+ s.homepage = 'https://github.com/NativElement/capacitor-msal-entra'
11
+ s.author = 'Nativelement'
12
+ s.source = { :git => 'https://github.com/NativElement/capacitor-msal-entra.git', :tag => s.version.to_s }
13
+ s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.dependency 'Capacitor'
16
+ s.dependency 'MSAL', '~> 1.2'
17
+ s.swift_version = '5.1'
18
+ end
package/README.md CHANGED
@@ -1,3 +1,461 @@
1
- # Temporary Holding Version
1
+ # @nativelement/capacitor-msal-entra
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Capacitor plugin wrapping the native MSAL SDKs (MSAL Android / MSAL iOS) and
4
+ `@azure/msal-browser` (web) for sign-in against Microsoft Entra External ID,
5
+ with secure native token caching instead of a hand-rolled browser+PKCE flow.
6
+
7
+ ## 1. Install into your Ionic app
8
+
9
+ ```bash
10
+ npm install @nativelement/capacitor-msal-entra
11
+ npx cap sync
12
+ ```
13
+
14
+ ## 2. Android setup
15
+
16
+ - `android/app/build.gradle`: no extra step needed, the plugin module
17
+ already declares the MSAL dependency. **But your app's root
18
+ `android/build.gradle` does need one addition**: MSAL pulls in
19
+ `com.microsoft.identity:common`, which transitively depends on
20
+ `com.microsoft.device.display:display-mask` — a package published only to
21
+ a Microsoft-hosted Azure DevOps Maven feed, not to Maven Central or
22
+ Google's repo. Without it, `:app:assembleDebug` fails at
23
+ `checkDebugAarMetadata` unable to resolve `display-mask`. Add this
24
+ repository to your app's `allprojects { repositories { ... } }` block (a
25
+ subproject-level `repositories {}`, including this plugin module's own, is
26
+ **not** enough — Gradle resolves the app module's full transitive
27
+ classpath using the app module's own repository list):
28
+ ```groovy
29
+ maven {
30
+ url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1'
31
+ name 'Duo-SDK-Feed'
32
+ }
33
+ ```
34
+ - Copy `android/src/main/res/raw/msal_config.json` into
35
+ `android/app/src/main/res/raw/msal_config.json` in your app and fill in:
36
+ - `client_id`: the Ionic app's registration client id
37
+ - `redirect_uri`: `msauth://<your.package.name>/<base64 signature hash>`
38
+ — generate the signature hash with `keytool` + `openssl` per the MSAL
39
+ Android docs, once per signing key (debug and release will differ). The
40
+ signature hash must be **URL-encoded** here (`+`→`%2B`, `/`→`%2F`,
41
+ `=`→`%3D`) — this is easy to get wrong since the *same* hash goes into
42
+ `AndroidManifest.xml` (below) **unencoded**.
43
+ - `authority_url`: your Entra External ID tenant's CIAM authority.
44
+ - In `AndroidManifest.xml`, add the `BrowserTabActivity` intent filter MSAL
45
+ needs to catch the redirect:
46
+ ```xml
47
+ <activity
48
+ android:name="com.microsoft.identity.client.BrowserTabActivity"
49
+ android:exported="true">
50
+ <intent-filter>
51
+ <action android:name="android.intent.action.VIEW" />
52
+ <category android:name="android.intent.category.DEFAULT" />
53
+ <category android:name="android.intent.category.BROWSABLE" />
54
+ <data
55
+ android:scheme="msauth"
56
+ android:host="<your.package.name>"
57
+ android:path="/<base64 signature hash, NOT URL-encoded>" />
58
+ </intent-filter>
59
+ </activity>
60
+ ```
61
+ The `android:path` value must have a leading `/` and use the raw
62
+ (non-URL-encoded) signature hash — unlike `redirect_uri` in
63
+ `msal_config.json` above.
64
+
65
+ ## 3. iOS setup
66
+
67
+ - Your app's `ios/App` must use CocoaPods, not Capacitor's default Swift
68
+ Package Manager integration — this plugin ships a podspec
69
+ (`NativelementCapacitorMsalEntra.podspec`), not a `Package.swift`, so Capacitor
70
+ CLI cannot include it via SPM. If `ios/App/CapApp-SPM` exists in your app,
71
+ remove it, delete any `CapApp-SPM` Swift package reference from the Xcode
72
+ project, and add an `ios/App/Podfile` (Capacitor's classic CocoaPods
73
+ template — see `ios/App-Podfile.example`) before running `cap sync`.
74
+ Mixing SPM-managed Capacitor core with a CocoaPods-only plugin is not
75
+ supported; pick CocoaPods for the whole app.
76
+ - Once on CocoaPods, no manual `Podfile` edits needed for this plugin:
77
+ `npx cap sync ios` reads this plugin's `package.json`
78
+ (`capacitor.ios.src: "ios"`), derives the pod name
79
+ `NativelementCapacitorMsalEntra` from the package name, and auto-injects a
80
+ `pod 'NativelementCapacitorMsalEntra', :path => ...` line into your app's
81
+ `ios/App/Podfile`, which pulls in MSAL transitively via the podspec's
82
+ `s.dependency 'MSAL'`. See `ios/App-Podfile.example` for exactly what
83
+ that generated block looks like. Just run `npx cap sync ios` then
84
+ `pod install` inside `ios/App` (`cap sync` does this for you if
85
+ CocoaPods is installed).
86
+ - Copy `ios/Plugin/msal_config.json` into your app's bundle (add it to the
87
+ Xcode target) and fill in `client_id`, `redirect_uri`, `authority_url`.
88
+ - In `Info.plist`:
89
+ - Add a `CFBundleURLTypes` entry with `CFBundleURLSchemes` containing your
90
+ redirect scheme, e.g. `msauth.com.example.myapp`.
91
+ - Add `LSApplicationQueriesSchemes` with `msauthv2` and `msauthv3` if you
92
+ want broker (Microsoft Authenticator) support.
93
+ - Add a **Keychain Sharing** entitlement. MSAL caches accounts/tokens in the
94
+ Keychain under its own shared access group; without this, interactive
95
+ `signIn()` still works (the result comes back in-memory) but any call that
96
+ reads the cache back — `getAccount()`, `acquireTokenSilent()`,
97
+ `signOut()` — fails with `OSStatus error -34018`
98
+ (`errSecMissingEntitlement`). Add an entitlements file (or enable "Keychain
99
+ Sharing" under Xcode's Signing & Capabilities tab) containing:
100
+ ```xml
101
+ <key>keychain-access-groups</key>
102
+ <array>
103
+ <string>$(AppIdentifierPrefix)com.microsoft.adalcache</string>
104
+ </array>
105
+ ```
106
+ and make sure it's wired up via the target's `CODE_SIGN_ENTITLEMENTS` build
107
+ setting.
108
+
109
+ ## 4. Web / PWA setup
110
+
111
+ Unlike earlier scaffolds of this plugin, the web platform is a real
112
+ implementation backed by [`@azure/msal-browser`](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser)
113
+ (added automatically as a dependency of this package) - not a stub. It's
114
+ used automatically whenever the app runs somewhere without the native
115
+ bridge: `ionic serve`, a browser tab, or an installed PWA.
116
+
117
+ - Add a **Single-page application** platform (not "Web" - that platform
118
+ type is for confidential/server-side clients and doesn't get CORS-allowed
119
+ on the token endpoint for the PKCE flow msal-browser uses, which fails
120
+ with an opaque CORS error) to the same Entra app registration used by
121
+ iOS/Android (Authentication -> Add a platform), with a redirect URI
122
+ matching where the app is actually served - e.g. `http://localhost:4200`
123
+ for this app's `ng serve` (`http://localhost:8100` if you use the Ionic
124
+ CLI's `ionic serve` instead, which defaults to a different port), or your
125
+ deployed PWA's origin. This is a separate platform entry from the
126
+ `msauth://...` redirect URIs iOS/Android use, and Entra allows a plain
127
+ `http://localhost:<port>` redirect URI here for local dev even though
128
+ other platforms require `https`.
129
+ - Copy `web/msal_config.json` from this plugin into your app's assets
130
+ folder as `assets/msal_config.json` (in an Ionic/Angular app: put it at
131
+ `src/assets/msal_config.json` - the default Angular CLI asset config
132
+ copies `src/assets/**` to `www/assets/**`, so this is fetched at
133
+ `assets/msal_config.json` relative to the app's `<base href>` at
134
+ runtime). Fill in:
135
+ - `client_id`: the Web/SPA platform's client id (usually the same app
136
+ registration as iOS/Android, just a different platform entry).
137
+ - `authority_url`: same CIAM authority as the other platforms, e.g.
138
+ `https://<tenant-subdomain>.ciamlogin.com/<tenant-subdomain>.onmicrosoft.com/`.
139
+ - `redirect_uri` / (optional) `known_authorities`: see the comments in
140
+ `web/msal_config.json`.
141
+ - No manifest/Info.plist-equivalent step - the redirect is handled entirely
142
+ by msal-browser inside the page, not an OS-level URL scheme.
143
+
144
+ ### Behavioral differences from native (important)
145
+
146
+ - **Interactive sign-in uses a full-page redirect, not a popup.** MSAL.js's
147
+ own guidance is that popups are "unreliable in Safari and mobile browsers
148
+ due to popup blocker and gesture constraints," while redirect is "the
149
+ most reliable interaction type across browsers, including Safari and
150
+ mobile browsers" - the deciding factor for a PWA. The consequence:
151
+ `signIn()` and `signOut({ clearBrowserSession: true })` **navigate the
152
+ browser away and their returned promises never resolve on web** - the
153
+ whole JS context is torn down and recreated once Entra redirects back,
154
+ unlike native where an in-app browser overlay returns control to the same
155
+ JS context. Don't block UI on those promises settling on web.
156
+ - **Call `getAccount()` on app startup** to pick up a sign-in that just
157
+ completed via redirect (see `HomePage.ngOnInit()` in the demo app for the
158
+ pattern) - this is how the result of `signIn()` actually reaches your app
159
+ on web, instead of via `signIn()`'s own return value.
160
+ - `signOut()` without `clearBrowserSession` clears the local cache only (no
161
+ navigation), same as native. With `clearBrowserSession: true` it also
162
+ redirects to Entra's logout endpoint to end the browser SSO session (same
163
+ never-resolves caveat as `signIn()`).
164
+ - The web token cache uses `localStorage` (not msal-browser's
165
+ `sessionStorage` default) so a signed-in user stays signed in across tab
166
+ closes/relaunches, matching native's persistent Keychain/
167
+ EncryptedSharedPreferences cache.
168
+ - Calling an interactive `signIn()` from inside `AuthInterceptor`'s
169
+ `UI_REQUIRED` fallback (see `auth.interceptor.ts`) will navigate the whole
170
+ app away mid-request on web - fine for a demo, but a real app should
171
+ surface a "session expired, please sign in again" state instead of
172
+ re-authenticating unprompted from inside an HTTP interceptor.
173
+
174
+ ## 5. Usage from Angular/Ionic
175
+
176
+ ```typescript
177
+ import { MsalAuth } from '@nativelement/capacitor-msal-entra';
178
+
179
+ const API_SCOPE = 'api://<api-client-id>/access_as_user';
180
+
181
+ async function signIn() {
182
+ const result = await MsalAuth.signIn({ scopes: [API_SCOPE] });
183
+ // result.accessToken -> attach as Authorization: Bearer <token> on API calls
184
+ // result.accountId -> stable per-user id (maps to the token's oid claim)
185
+ }
186
+
187
+ async function getFreshToken(): Promise<string> {
188
+ try {
189
+ const result = await MsalAuth.acquireTokenSilent({ scopes: [API_SCOPE] });
190
+ return result.accessToken;
191
+ } catch (e: any) {
192
+ if (e.code === 'UI_REQUIRED') {
193
+ const result = await MsalAuth.signIn({ scopes: [API_SCOPE] });
194
+ return result.accessToken;
195
+ }
196
+ throw e;
197
+ }
198
+ }
199
+
200
+ async function signOut() {
201
+ await MsalAuth.signOut();
202
+ }
203
+ ```
204
+
205
+ A typical pattern is an Angular `AuthInterceptor` that calls
206
+ `getFreshToken()` before every outgoing API request, catching `UI_REQUIRED`
207
+ to trigger a re-auth prompt only when silent renewal genuinely fails.
208
+
209
+ ### Federated sign-in (Google, Apple, ...) via `domainHint` — iOS and web
210
+
211
+ If the Entra External ID tenant's user flow has social identity providers
212
+ configured (see your tenant admin's setup, or the "Add Google/Apple as an
213
+ identity provider" docs), the hosted Entra sign-in page shows buttons for
214
+ each one alongside email/password. Pass `domainHint` to `signIn()` to skip
215
+ that picker page and go straight to a specific provider's own login screen:
216
+
217
+ ```typescript
218
+ await MsalAuth.signIn({ scopes: [], domainHint: 'Google' }); // capital G, case-sensitive
219
+ await MsalAuth.signIn({ scopes: [], domainHint: 'apple' });
220
+ ```
221
+
222
+ This sends `domain_hint=<value>` as an extra query parameter on the
223
+ authorize request (`MSALInteractiveTokenParameters.extraQueryParameters` on
224
+ iOS; the `domainHint` field on msal-browser's `loginRedirect()` request on
225
+ web). It has no effect on `acquireTokenSilent()`, does not yet work on
226
+ **Android** (that implementation does not read this option — see Known
227
+ gaps), and only works if the provider is actually enabled on the tenant's
228
+ user flow. On web it's subject to the same caveat as a plain `signIn()`
229
+ call - see "Web / PWA setup" above: the browser navigates away and
230
+ `signIn()`'s promise never resolves, so a "Sign in with Google" button
231
+ built this way still needs the `getAccount()`-on-startup pattern, not a
232
+ `.then()` off the `signIn()` call itself.
233
+
234
+ **The hint value must be the bare provider name for social IdPs** — Google,
235
+ Facebook, and Apple are `'Google'` (capital G — case-sensitive, `'google'`
236
+ fails with `AADSTS90023: 'google' '' pair is not an external identity
237
+ provider`), `'facebook'`, `'apple'` respectively (this is Microsoft's "issuer
238
+ acceleration" mechanism). A domain-shaped value like
239
+ `'apple.com'` is a *different*, unrelated mechanism ("domain acceleration")
240
+ reserved for custom SAML/WS-Fed IdPs matched by a configured domain — passing
241
+ one for a social IdP won't just fail cleanly, it can misroute the user to
242
+ whatever Entra associates with that literal domain (e.g. `'apple.com'`
243
+ routing to Apple's own business identity portal instead of "Sign in with
244
+ Apple"). For a custom OIDC provider, use the domain segment of its issuer URI
245
+ instead (e.g. `'www.linkedin.com'` for LinkedIn).
246
+
247
+ ## 6. Backend API: validating the access token
248
+
249
+ The `accessToken` returned by `signIn()` / `acquireTokenSilent()` is a JWT
250
+ issued by the External ID (CIAM) tenant, scoped to your API via the
251
+ `scopes` you requested (e.g. `api://<api-client-id>/read`). The client
252
+ sends it as `Authorization: Bearer <accessToken>`; your API must validate
253
+ it on every request rather than trust it blindly. For a CIAM tenant the
254
+ token has this shape (decode any token at [jwt.ms](https://jwt.ms) to
255
+ check yours):
256
+
257
+ - `iss`: `https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0`
258
+ - `aud`: your API's Application (client) ID, or its Application ID URI
259
+ (e.g. `api://<api-client-id>`) if you set one under **Expose an API**
260
+ - `scp`: a space-separated list of the *bare* scope names the token was
261
+ granted, e.g. `"read write"` — not the full `api://...` URI
262
+ - signature: RS256, verified against the tenant's JWKS
263
+ (`https://<tenant-id>.ciamlogin.com/<tenant-id>/discovery/v2.0/keys`,
264
+ discoverable from
265
+ `https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0/.well-known/openid-configuration`)
266
+
267
+ Never skip signature/issuer/audience checks and never decode-and-trust a
268
+ token client-side — validation has to happen on the API.
269
+
270
+ ### .NET (ASP.NET Core + Microsoft.Identity.Web)
271
+
272
+ This follows Microsoft's own pattern for protecting an ASP.NET Core API
273
+ against an External ID tenant.
274
+
275
+ ```bash
276
+ dotnet add package Microsoft.Identity.Web
277
+ ```
278
+
279
+ `appsettings.json`:
280
+
281
+ ```json
282
+ {
283
+ "AzureAd": {
284
+ "Instance": "https://<tenant-subdomain>.ciamlogin.com/",
285
+ "TenantId": "<tenant-id>",
286
+ "ClientId": "<api-client-id>",
287
+ "Scopes": {
288
+ "Read": ["read", "write"],
289
+ "Write": ["write"]
290
+ }
291
+ }
292
+ }
293
+ ```
294
+
295
+ `Program.cs`:
296
+
297
+ ```csharp
298
+ using Microsoft.AspNetCore.Authentication.JwtBearer;
299
+ using Microsoft.Identity.Web;
300
+
301
+ var builder = WebApplication.CreateBuilder(args);
302
+
303
+ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
304
+ .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
305
+
306
+ builder.Services.AddAuthorization();
307
+ builder.Services.AddControllers();
308
+
309
+ var app = builder.Build();
310
+ app.UseAuthentication();
311
+ app.UseAuthorization();
312
+ app.MapControllers();
313
+ app.Run();
314
+ ```
315
+
316
+ Controller:
317
+
318
+ ```csharp
319
+ using Microsoft.AspNetCore.Authorization;
320
+ using Microsoft.AspNetCore.Mvc;
321
+ using Microsoft.Identity.Web.Resource;
322
+
323
+ [Authorize]
324
+ [ApiController]
325
+ [Route("api/[controller]")]
326
+ public class WidgetsController : ControllerBase
327
+ {
328
+ [HttpGet]
329
+ [RequiredScopeOrAppPermission(RequiredScopesConfigurationKey = "AzureAd:Scopes:Read")]
330
+ public IActionResult Get() => Ok(new[] { "widget-1", "widget-2" });
331
+
332
+ [HttpPost]
333
+ [RequiredScopeOrAppPermission(RequiredScopesConfigurationKey = "AzureAd:Scopes:Write")]
334
+ public IActionResult Post([FromBody] object widget) => Created("", widget);
335
+ }
336
+ ```
337
+
338
+ `[Authorize]` rejects anything that fails signature/issuer/audience/expiry
339
+ validation; `Microsoft.Identity.Web` handles all of that from the
340
+ `AzureAd` config above. `[RequiredScopeOrAppPermission]` then checks the
341
+ token's `scp` (or `roles`, for app-only calls) against the configured
342
+ scope list and returns `403` if it doesn't match.
343
+
344
+ ### Express.js (Node)
345
+
346
+ There's no CIAM-specific Microsoft package for Express — `passport-azure-ad`
347
+ predates External ID and isn't a good fit. The standard approach is
348
+ `express-jwt` + `jwks-rsa`, which is generic OIDC/JWT validation, not
349
+ Microsoft-specific:
350
+
351
+ ```bash
352
+ npm install express express-jwt jwks-rsa
353
+ ```
354
+
355
+ ```javascript
356
+ const express = require('express');
357
+ const { expressjwt: jwt } = require('express-jwt');
358
+ const jwksRsa = require('jwks-rsa');
359
+
360
+ const TENANT_ID = '<tenant-id>';
361
+ const API_CLIENT_ID = '<api-client-id>'; // or 'api://<api-client-id>' if you set an App ID URI
362
+ const ISSUER = `https://${TENANT_ID}.ciamlogin.com/${TENANT_ID}/v2.0`;
363
+
364
+ const checkJwt = jwt({
365
+ secret: jwksRsa.expressJwtSecret({
366
+ cache: true,
367
+ rateLimit: true,
368
+ jwksRequestsPerMinute: 5,
369
+ jwksUri: `${ISSUER}/.well-known/openid-configuration`, // resolved to jwks_uri internally
370
+ }),
371
+ issuer: ISSUER,
372
+ audience: API_CLIENT_ID,
373
+ algorithms: ['RS256'],
374
+ });
375
+
376
+ function requireScope(...allowed) {
377
+ return (req, res, next) => {
378
+ const granted = (req.auth?.scp ?? '').split(' ');
379
+ if (allowed.some((s) => granted.includes(s))) return next();
380
+ res.status(403).json({ error: 'insufficient_scope' });
381
+ };
382
+ }
383
+
384
+ const app = express();
385
+ app.use(express.json());
386
+
387
+ app.get('/api/widgets', checkJwt, requireScope('read', 'write'), (req, res) => {
388
+ res.json(['widget-1', 'widget-2']);
389
+ });
390
+
391
+ app.post('/api/widgets', checkJwt, requireScope('write'), (req, res) => {
392
+ res.status(201).json(req.body);
393
+ });
394
+
395
+ app.listen(3000);
396
+ ```
397
+
398
+ `jwks-rsa`'s `expressJwtSecret` accepts a `jwksUri` — pass the
399
+ `.well-known/openid-configuration` discovery URL (as above) so it doesn't
400
+ depend on hardcoding the tenant's JWKS path, which lets Microsoft rotate
401
+ the endpoint shape without breaking your API. `express-jwt` verifies
402
+ signature, `issuer`, `audience`, and expiry before your route handler
403
+ ever runs; `requireScope` is a thin extra check on top for per-route
404
+ authorization, mirroring what `[RequiredScopeOrAppPermission]` does on
405
+ the .NET side.
406
+
407
+ References: [Tutorial: Build and secure an ASP.NET Core web API](https://learn.microsoft.com/en-us/entra/identity-platform/tutorial-web-api-dotnet-core-build-app),
408
+ [Validating tokens](https://learn.microsoft.com/en-us/azure/active-directory/develop/access-tokens#validating-tokens).
409
+
410
+ ## 7. Optional: enable "Forgot password" on the sign-in page
411
+
412
+ This is tenant/admin center configuration, not an app code change — it
413
+ applies to the hosted (browser-delegated) sign-in page this plugin
414
+ redirects to on all platforms.
415
+
416
+ 1. **Prerequisite**: your user flow's sign-up must register **Email with
417
+ password** as an identity provider — check under **Entra ID → External
418
+ Identities → User flows → (your flow) → Identity providers**.
419
+ 2. **Enable an authentication method for the reset** (required for
420
+ "Forgot password?" to actually work):
421
+ - **Entra ID → Authentication methods → Policies → Method → Email OTP**
422
+ - Under **Enable and Target**, turn it on, choose **All users** (or
423
+ select groups) → **Save**
424
+ - (SMS is an alternative, but it's a paid add-on and requires users to
425
+ have registered a phone number as an MFA method — Email OTP is the
426
+ simpler default)
427
+ 3. **Show the "Forgot password?" link on the sign-in page** (this is the
428
+ actual toggle — SSPR can be enabled per step 2 but the link stays
429
+ hidden until you do this):
430
+ - Search bar → **Company Branding → Default sign-in** tab → **Edit**
431
+ - Go to the **Sign-in form** tab, scroll to **Self-service password
432
+ reset**
433
+ - Check **Show self-service password reset**
434
+ - Optional: override the default "Forgot password" text (**Password
435
+ collection display text**), the "Can't access your account" text
436
+ (**Account collection display text**), or point it at a **Common
437
+ URL** of your own instead of Microsoft's default flow
438
+ - **Review + save**
439
+
440
+ **Where to look when testing**: the link shows up on the **password**
441
+ step, not the first screen — enter the email and select **Next** first,
442
+ then "Forgot password?" appears alongside the password field.
443
+
444
+ If the link still doesn't appear after this, see [Enable self-service
445
+ password reset](https://learn.microsoft.com/en-us/entra/external-id/customers/how-to-enable-password-reset-customers)
446
+ — common causes are not completing the branding wizard's final Review +
447
+ save step, branding propagation delay (wait 15-30 min, test in an
448
+ incognito window), or a per-user-flow language customization overriding
449
+ the Company Branding default.
450
+
451
+ ## Notes
452
+
453
+ - `accountId` is the local MSAL account identifier, not the raw `oid` claim
454
+ — decode the returned `idToken` (or have your backend read `oid` from the
455
+ validated access/id token) if you need the tenant's object id specifically.
456
+ - This plugin assumes single-tenant, single-account usage (one signed-in
457
+ user at a time). Both native SDKs support multiple accounts if you need
458
+ account switching later.
459
+ - Test the Android redirect URI signature hash separately for debug and
460
+ release keystores — this is the single most common MSAL Android setup
461
+ failure.
@@ -0,0 +1,55 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ }
4
+
5
+ buildscript {
6
+ repositories {
7
+ google()
8
+ mavenCentral()
9
+ }
10
+ dependencies {
11
+ classpath 'com.android.tools.build:gradle:8.2.1'
12
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
13
+ }
14
+ }
15
+
16
+ apply plugin: 'com.android.library'
17
+ apply plugin: 'org.jetbrains.kotlin.android'
18
+
19
+ android {
20
+ namespace "io.nativelement.msalentra"
21
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
22
+ defaultConfig {
23
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
24
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
25
+ }
26
+ compileOptions {
27
+ sourceCompatibility JavaVersion.VERSION_1_8
28
+ targetCompatibility JavaVersion.VERSION_1_8
29
+ }
30
+ kotlinOptions {
31
+ jvmTarget = "1.8"
32
+ }
33
+ }
34
+
35
+ repositories {
36
+ google()
37
+ mavenCentral()
38
+ // Required for com.microsoft.device.display:display-mask, a transitive
39
+ // dependency of com.microsoft.identity:common (pulled in by MSAL) that
40
+ // isn't published to Maven Central/Google.
41
+ maven {
42
+ url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1'
43
+ name 'Duo-SDK-Feed'
44
+ }
45
+ }
46
+
47
+ dependencies {
48
+ implementation project(':capacitor-android')
49
+ implementation "com.microsoft.identity.client:msal:5.10.2"
50
+ // capacitor-android's Plugin.getActivity() returns AppCompatActivity; Kotlin
51
+ // requires this type to be directly resolvable on this module's own
52
+ // classpath (not just transitively via capacitor-android) to compile
53
+ // against it.
54
+ implementation "androidx.appcompat:appcompat:1.6.1"
55
+ }