@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 +10 -0
- package/LICENSE +21 -0
- package/NativelementCapacitorMsalEntra.podspec +18 -0
- package/README.md +460 -2
- package/android/build.gradle +55 -0
- package/android/src/main/java/io/nativelement/msalentra/MsalAuthPlugin.kt +273 -0
- package/android/src/main/res/raw/msal_config.json +14 -0
- package/dist/esm/definitions.d.ts +93 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +41 -0
- package/dist/esm/web.js +187 -0
- package/dist/plugin.cjs.js +202 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +204 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Plugin/MsalAuthPlugin.swift +204 -0
- package/ios/Plugin/msal_config.json +5 -0
- package/package.json +68 -4
- package/web/msal_config.json +6 -0
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
|
-
#
|
|
1
|
+
# @nativelement/capacitor-msal-entra
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|