@nativelement/capacitor-msal-entra
v1.0.2
Published
Capacitor plugin wrapping native MSAL SDKs (Android/iOS) and msal-browser (web) for Microsoft Entra External ID authentication
Maintainers
Readme
@nativelement/capacitor-msal-entra
Capacitor plugin wrapping the native MSAL SDKs (MSAL Android / MSAL iOS) and
@azure/msal-browser (web) for sign-in against Microsoft Entra External ID,
with secure native token caching instead of a hand-rolled browser+PKCE flow.
Resources
- Tutorial: Entra External ID Login in Ionic: A Capacitor MSAL Plugin Tutorial — a step-by-step walkthrough of configuring Microsoft Entra External ID and integrating this plugin into an Ionic app.
- npm package: @nativelement/capacitor-msal-entra
- Source & issues: github.com/NativElement/capacitor-msal-entra
1. Install into your Ionic app
npm install @nativelement/capacitor-msal-entra
npx cap sync2. Android setup
android/app/build.gradle: no extra step needed, the plugin module already declares the MSAL dependency. But your app's rootandroid/build.gradledoes need one addition: MSAL pulls incom.microsoft.identity:common, which transitively depends oncom.microsoft.device.display:display-mask— a package published only to a Microsoft-hosted Azure DevOps Maven feed, not to Maven Central or Google's repo. Without it,:app:assembleDebugfails atcheckDebugAarMetadataunable to resolvedisplay-mask. Add this repository to your app'sallprojects { repositories { ... } }block (a subproject-levelrepositories {}, including this plugin module's own, is not enough — Gradle resolves the app module's full transitive classpath using the app module's own repository list):maven { url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1' name 'Duo-SDK-Feed' }- Copy
android/src/main/res/raw/msal_config.jsonintoandroid/app/src/main/res/raw/msal_config.jsonin your app and fill in:client_id: the Ionic app's registration client idredirect_uri:msauth://<your.package.name>/<base64 signature hash>— generate the signature hash withkeytool+opensslper the MSAL Android docs, once per signing key (debug and release will differ). The signature hash must be URL-encoded here (+→%2B,/→%2F,=→%3D) — this is easy to get wrong since the same hash goes intoAndroidManifest.xml(below) unencoded.authority_url: your Entra External ID tenant's CIAM authority.
- In
AndroidManifest.xml, add theBrowserTabActivityintent filter MSAL needs to catch the redirect:
The<activity android:name="com.microsoft.identity.client.BrowserTabActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="msauth" android:host="<your.package.name>" android:path="/<base64 signature hash, NOT URL-encoded>" /> </intent-filter> </activity>android:pathvalue must have a leading/and use the raw (non-URL-encoded) signature hash — unlikeredirect_uriinmsal_config.jsonabove.
3. iOS setup
- Your app's
ios/Appmust use CocoaPods, not Capacitor's default Swift Package Manager integration — this plugin ships a podspec (NativelementCapacitorMsalEntra.podspec), not aPackage.swift, so Capacitor CLI cannot include it via SPM. Ifios/App/CapApp-SPMexists in your app, remove it, delete anyCapApp-SPMSwift package reference from the Xcode project, and add anios/App/Podfile(Capacitor's classic CocoaPods template — seeios/App-Podfile.example) before runningcap sync. Mixing SPM-managed Capacitor core with a CocoaPods-only plugin is not supported; pick CocoaPods for the whole app. - Once on CocoaPods, no manual
Podfileedits needed for this plugin:npx cap sync iosreads this plugin'spackage.json(capacitor.ios.src: "ios"), derives the pod nameNativelementCapacitorMsalEntrafrom the package name, and auto-injects apod 'NativelementCapacitorMsalEntra', :path => ...line into your app'sios/App/Podfile, which pulls in MSAL transitively via the podspec'ss.dependency 'MSAL'. Seeios/App-Podfile.examplefor exactly what that generated block looks like. Just runnpx cap sync iosthenpod installinsideios/App(cap syncdoes this for you if CocoaPods is installed). - Copy
ios/Plugin/msal_config.jsoninto your app's bundle (add it to the Xcode target) and fill inclient_id,redirect_uri,authority_url. - In
Info.plist:- Add a
CFBundleURLTypesentry withCFBundleURLSchemescontaining your redirect scheme, e.g.msauth.com.example.myapp. - Add
LSApplicationQueriesSchemeswithmsauthv2andmsauthv3if you want broker (Microsoft Authenticator) support.
- Add a
- Add a Keychain Sharing entitlement. MSAL caches accounts/tokens in the
Keychain under its own shared access group; without this, interactive
signIn()still works (the result comes back in-memory) but any call that reads the cache back —getAccount(),acquireTokenSilent(),signOut()— fails withOSStatus error -34018(errSecMissingEntitlement). Add an entitlements file (or enable "Keychain Sharing" under Xcode's Signing & Capabilities tab) containing:
and make sure it's wired up via the target's<key>keychain-access-groups</key> <array> <string>$(AppIdentifierPrefix)com.microsoft.adalcache</string> </array>CODE_SIGN_ENTITLEMENTSbuild setting.
4. Web / PWA setup
Unlike earlier scaffolds of this plugin, the web platform is a real
implementation backed by @azure/msal-browser
(added automatically as a dependency of this package) - not a stub. It's
used automatically whenever the app runs somewhere without the native
bridge: ionic serve, a browser tab, or an installed PWA.
- Add a Single-page application platform (not "Web" - that platform
type is for confidential/server-side clients and doesn't get CORS-allowed
on the token endpoint for the PKCE flow msal-browser uses, which fails
with an opaque CORS error) to the same Entra app registration used by
iOS/Android (Authentication -> Add a platform), with a redirect URI
matching where the app is actually served - e.g.
http://localhost:4200for this app'sng serve(http://localhost:8100if you use the Ionic CLI'sionic serveinstead, which defaults to a different port), or your deployed PWA's origin. This is a separate platform entry from themsauth://...redirect URIs iOS/Android use, and Entra allows a plainhttp://localhost:<port>redirect URI here for local dev even though other platforms requirehttps. - Copy
web/msal_config.jsonfrom this plugin into your app's assets folder asassets/msal_config.json(in an Ionic/Angular app: put it atsrc/assets/msal_config.json- the default Angular CLI asset config copiessrc/assets/**towww/assets/**, so this is fetched atassets/msal_config.jsonrelative to the app's<base href>at runtime). Fill in:client_id: the Web/SPA platform's client id (usually the same app registration as iOS/Android, just a different platform entry).authority_url: same CIAM authority as the other platforms, e.g.https://<tenant-subdomain>.ciamlogin.com/<tenant-subdomain>.onmicrosoft.com/.redirect_uri/ (optional)known_authorities: see the comments inweb/msal_config.json.
- No manifest/Info.plist-equivalent step - the redirect is handled entirely by msal-browser inside the page, not an OS-level URL scheme.
Behavioral differences from native (important)
- Interactive sign-in uses a full-page redirect, not a popup. MSAL.js's
own guidance is that popups are "unreliable in Safari and mobile browsers
due to popup blocker and gesture constraints," while redirect is "the
most reliable interaction type across browsers, including Safari and
mobile browsers" - the deciding factor for a PWA. The consequence:
signIn()andsignOut({ clearBrowserSession: true })navigate the browser away and their returned promises never resolve on web - the whole JS context is torn down and recreated once Entra redirects back, unlike native where an in-app browser overlay returns control to the same JS context. Don't block UI on those promises settling on web. - Call
getAccount()on app startup to pick up a sign-in that just completed via redirect (seeHomePage.ngOnInit()in the demo app for the pattern) - this is how the result ofsignIn()actually reaches your app on web, instead of viasignIn()'s own return value. signOut()withoutclearBrowserSessionclears the local cache only (no navigation), same as native. WithclearBrowserSession: trueit also redirects to Entra's logout endpoint to end the browser SSO session (same never-resolves caveat assignIn()).- The web token cache uses
localStorage(not msal-browser'ssessionStoragedefault) so a signed-in user stays signed in across tab closes/relaunches, matching native's persistent Keychain/ EncryptedSharedPreferences cache. - Calling an interactive
signIn()from insideAuthInterceptor'sUI_REQUIREDfallback (seeauth.interceptor.ts) will navigate the whole app away mid-request on web - fine for a demo, but a real app should surface a "session expired, please sign in again" state instead of re-authenticating unprompted from inside an HTTP interceptor.
5. Usage from Angular/Ionic
import { MsalAuth } from '@nativelement/capacitor-msal-entra';
const API_SCOPE = 'api://<api-client-id>/access_as_user';
async function signIn() {
const result = await MsalAuth.signIn({ scopes: [API_SCOPE] });
// result.accessToken -> attach as Authorization: Bearer <token> on API calls
// result.accountId -> stable per-user id (maps to the token's oid claim)
}
async function getFreshToken(): Promise<string> {
try {
const result = await MsalAuth.acquireTokenSilent({ scopes: [API_SCOPE] });
return result.accessToken;
} catch (e: any) {
if (e.code === 'UI_REQUIRED') {
const result = await MsalAuth.signIn({ scopes: [API_SCOPE] });
return result.accessToken;
}
throw e;
}
}
async function signOut() {
await MsalAuth.signOut();
}A typical pattern is an Angular AuthInterceptor that calls
getFreshToken() before every outgoing API request, catching UI_REQUIRED
to trigger a re-auth prompt only when silent renewal genuinely fails.
Federated sign-in (Google, Apple, ...) via domainHint — iOS and web
If the Entra External ID tenant's user flow has social identity providers
configured (see your tenant admin's setup, or the "Add Google/Apple as an
identity provider" docs), the hosted Entra sign-in page shows buttons for
each one alongside email/password. Pass domainHint to signIn() to skip
that picker page and go straight to a specific provider's own login screen:
await MsalAuth.signIn({ scopes: [], domainHint: 'Google' }); // capital G, case-sensitive
await MsalAuth.signIn({ scopes: [], domainHint: 'apple' });This sends domain_hint=<value> as an extra query parameter on the
authorize request (MSALInteractiveTokenParameters.extraQueryParameters on
iOS; the domainHint field on msal-browser's loginRedirect() request on
web). It has no effect on acquireTokenSilent(), does not yet work on
Android (that implementation does not read this option — see Known
gaps), and only works if the provider is actually enabled on the tenant's
user flow. On web it's subject to the same caveat as a plain signIn()
call - see "Web / PWA setup" above: the browser navigates away and
signIn()'s promise never resolves, so a "Sign in with Google" button
built this way still needs the getAccount()-on-startup pattern, not a
.then() off the signIn() call itself.
The hint value must be the bare provider name for social IdPs — Google,
Facebook, and Apple are 'Google' (capital G — case-sensitive, 'google'
fails with AADSTS90023: 'google' '' pair is not an external identity
provider), 'facebook', 'apple' respectively (this is Microsoft's "issuer
acceleration" mechanism). A domain-shaped value like
'apple.com' is a different, unrelated mechanism ("domain acceleration")
reserved for custom SAML/WS-Fed IdPs matched by a configured domain — passing
one for a social IdP won't just fail cleanly, it can misroute the user to
whatever Entra associates with that literal domain (e.g. 'apple.com'
routing to Apple's own business identity portal instead of "Sign in with
Apple"). For a custom OIDC provider, use the domain segment of its issuer URI
instead (e.g. 'www.linkedin.com' for LinkedIn).
6. Backend API: validating the access token
The accessToken returned by signIn() / acquireTokenSilent() is a JWT
issued by the External ID (CIAM) tenant, scoped to your API via the
scopes you requested (e.g. api://<api-client-id>/read). The client
sends it as Authorization: Bearer <accessToken>; your API must validate
it on every request rather than trust it blindly. For a CIAM tenant the
token has this shape (decode any token at jwt.ms to
check yours):
iss:https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0aud: your API's Application (client) ID, or its Application ID URI (e.g.api://<api-client-id>) if you set one under Expose an APIscp: a space-separated list of the bare scope names the token was granted, e.g."read write"— not the fullapi://...URI- signature: RS256, verified against the tenant's JWKS
(
https://<tenant-id>.ciamlogin.com/<tenant-id>/discovery/v2.0/keys, discoverable fromhttps://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0/.well-known/openid-configuration)
Never skip signature/issuer/audience checks and never decode-and-trust a token client-side — validation has to happen on the API.
.NET (ASP.NET Core + Microsoft.Identity.Web)
This follows Microsoft's own pattern for protecting an ASP.NET Core API against an External ID tenant.
dotnet add package Microsoft.Identity.Webappsettings.json:
{
"AzureAd": {
"Instance": "https://<tenant-subdomain>.ciamlogin.com/",
"TenantId": "<tenant-id>",
"ClientId": "<api-client-id>",
"Scopes": {
"Read": ["read", "write"],
"Write": ["write"]
}
}
}Program.cs:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();Controller:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web.Resource;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class WidgetsController : ControllerBase
{
[HttpGet]
[RequiredScopeOrAppPermission(RequiredScopesConfigurationKey = "AzureAd:Scopes:Read")]
public IActionResult Get() => Ok(new[] { "widget-1", "widget-2" });
[HttpPost]
[RequiredScopeOrAppPermission(RequiredScopesConfigurationKey = "AzureAd:Scopes:Write")]
public IActionResult Post([FromBody] object widget) => Created("", widget);
}[Authorize] rejects anything that fails signature/issuer/audience/expiry
validation; Microsoft.Identity.Web handles all of that from the
AzureAd config above. [RequiredScopeOrAppPermission] then checks the
token's scp (or roles, for app-only calls) against the configured
scope list and returns 403 if it doesn't match.
Express.js (Node)
There's no CIAM-specific Microsoft package for Express — passport-azure-ad
predates External ID and isn't a good fit. The standard approach is
express-jwt + jwks-rsa, which is generic OIDC/JWT validation, not
Microsoft-specific:
npm install express express-jwt jwks-rsaconst express = require('express');
const { expressjwt: jwt } = require('express-jwt');
const jwksRsa = require('jwks-rsa');
const TENANT_ID = '<tenant-id>';
const API_CLIENT_ID = '<api-client-id>'; // or 'api://<api-client-id>' if you set an App ID URI
const ISSUER = `https://${TENANT_ID}.ciamlogin.com/${TENANT_ID}/v2.0`;
const checkJwt = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${ISSUER}/.well-known/openid-configuration`, // resolved to jwks_uri internally
}),
issuer: ISSUER,
audience: API_CLIENT_ID,
algorithms: ['RS256'],
});
function requireScope(...allowed) {
return (req, res, next) => {
const granted = (req.auth?.scp ?? '').split(' ');
if (allowed.some((s) => granted.includes(s))) return next();
res.status(403).json({ error: 'insufficient_scope' });
};
}
const app = express();
app.use(express.json());
app.get('/api/widgets', checkJwt, requireScope('read', 'write'), (req, res) => {
res.json(['widget-1', 'widget-2']);
});
app.post('/api/widgets', checkJwt, requireScope('write'), (req, res) => {
res.status(201).json(req.body);
});
app.listen(3000);jwks-rsa's expressJwtSecret accepts a jwksUri — pass the
.well-known/openid-configuration discovery URL (as above) so it doesn't
depend on hardcoding the tenant's JWKS path, which lets Microsoft rotate
the endpoint shape without breaking your API. express-jwt verifies
signature, issuer, audience, and expiry before your route handler
ever runs; requireScope is a thin extra check on top for per-route
authorization, mirroring what [RequiredScopeOrAppPermission] does on
the .NET side.
References: Tutorial: Build and secure an ASP.NET Core web API, Validating tokens.
7. Optional: enable "Forgot password" on the sign-in page
This is tenant/admin center configuration, not an app code change — it applies to the hosted (browser-delegated) sign-in page this plugin redirects to on all platforms.
- Prerequisite: your user flow's sign-up must register Email with password as an identity provider — check under Entra ID → External Identities → User flows → (your flow) → Identity providers.
- Enable an authentication method for the reset (required for
"Forgot password?" to actually work):
- Entra ID → Authentication methods → Policies → Method → Email OTP
- Under Enable and Target, turn it on, choose All users (or select groups) → Save
- (SMS is an alternative, but it's a paid add-on and requires users to have registered a phone number as an MFA method — Email OTP is the simpler default)
- Show the "Forgot password?" link on the sign-in page (this is the
actual toggle — SSPR can be enabled per step 2 but the link stays
hidden until you do this):
- Search bar → Company Branding → Default sign-in tab → Edit
- Go to the Sign-in form tab, scroll to Self-service password reset
- Check Show self-service password reset
- Optional: override the default "Forgot password" text (Password collection display text), the "Can't access your account" text (Account collection display text), or point it at a Common URL of your own instead of Microsoft's default flow
- Review + save
Where to look when testing: the link shows up on the password step, not the first screen — enter the email and select Next first, then "Forgot password?" appears alongside the password field.
If the link still doesn't appear after this, see Enable self-service password reset — common causes are not completing the branding wizard's final Review + save step, branding propagation delay (wait 15-30 min, test in an incognito window), or a per-user-flow language customization overriding the Company Branding default.
Notes
accountIdis the local MSAL account identifier, not the rawoidclaim — decode the returnedidToken(or have your backend readoidfrom the validated access/id token) if you need the tenant's object id specifically.- This plugin assumes single-tenant, single-account usage (one signed-in user at a time). Both native SDKs support multiple accounts if you need account switching later.
- Test the Android redirect URI signature hash separately for debug and release keystores — this is the single most common MSAL Android setup failure.
