@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
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
package io.nativelement.msalentra
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.getcapacitor.JSArray
|
|
5
|
+
import com.getcapacitor.JSObject
|
|
6
|
+
import com.getcapacitor.Plugin
|
|
7
|
+
import com.getcapacitor.PluginCall
|
|
8
|
+
import com.getcapacitor.PluginMethod
|
|
9
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
10
|
+
import com.microsoft.identity.client.AuthenticationCallback
|
|
11
|
+
import com.microsoft.identity.client.IAccount
|
|
12
|
+
import com.microsoft.identity.client.IAuthenticationResult
|
|
13
|
+
import com.microsoft.identity.client.IMultipleAccountPublicClientApplication
|
|
14
|
+
import com.microsoft.identity.client.IPublicClientApplication
|
|
15
|
+
import com.microsoft.identity.client.PublicClientApplication
|
|
16
|
+
import com.microsoft.identity.client.SilentAuthenticationCallback
|
|
17
|
+
import com.microsoft.identity.client.exception.MsalClientException
|
|
18
|
+
import com.microsoft.identity.client.exception.MsalException
|
|
19
|
+
import com.microsoft.identity.client.exception.MsalUiRequiredException
|
|
20
|
+
import org.json.JSONObject
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wraps the MSAL Android SDK (com.microsoft.identity.client:msal) so the
|
|
24
|
+
* Ionic/Angular layer only ever deals with plain JS objects and promises.
|
|
25
|
+
*
|
|
26
|
+
* Requires:
|
|
27
|
+
* implementation 'com.microsoft.identity.client:msal:5.+'
|
|
28
|
+
* and a msal_config.json under android/app/src/main/res/raw/ describing
|
|
29
|
+
* the client id, redirect uri, and Entra External ID authority.
|
|
30
|
+
*/
|
|
31
|
+
@CapacitorPlugin(name = "MsalAuth")
|
|
32
|
+
class MsalAuthPlugin : Plugin() {
|
|
33
|
+
|
|
34
|
+
private var pca: IMultipleAccountPublicClientApplication? = null
|
|
35
|
+
private val TAG = "MsalAuthPlugin"
|
|
36
|
+
|
|
37
|
+
override fun load() {
|
|
38
|
+
super.load()
|
|
39
|
+
val configResId = context.resources.getIdentifier(
|
|
40
|
+
"msal_config", "raw", context.packageName
|
|
41
|
+
)
|
|
42
|
+
PublicClientApplication.createMultipleAccountPublicClientApplication(
|
|
43
|
+
context,
|
|
44
|
+
configResId,
|
|
45
|
+
object : IPublicClientApplication.IMultipleAccountApplicationCreatedListener {
|
|
46
|
+
override fun onCreated(application: IMultipleAccountPublicClientApplication) {
|
|
47
|
+
pca = application
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override fun onError(exception: MsalException) {
|
|
51
|
+
Log.e(TAG, "Failed to initialize MSAL", exception)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@PluginMethod
|
|
58
|
+
fun signIn(call: PluginCall) {
|
|
59
|
+
// 'openid', 'profile' and 'offline_access' are reserved scopes MSAL
|
|
60
|
+
// adds automatically, and callers should only pass scopes for a
|
|
61
|
+
// specific resource - but unlike MSAL iOS (which accepts an empty
|
|
62
|
+
// array), MSAL Android's TokenParameters.withScopes() throws
|
|
63
|
+
// IllegalArgumentException on an empty list. Default to "openid" -
|
|
64
|
+
// a standard, always-safe OIDC scope - so a caller passing [] (the
|
|
65
|
+
// common case for a bare sign-in) works on both platforms.
|
|
66
|
+
val requestedScopes = call.getArray("scopes")?.toList<String>() ?: emptyList()
|
|
67
|
+
val scopes = requestedScopes.ifEmpty { listOf("openid") }
|
|
68
|
+
val application = pca
|
|
69
|
+
if (application == null) {
|
|
70
|
+
call.reject("MSAL not initialized yet, try again shortly", "NOT_INITIALIZED")
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
val activity = activity ?: run {
|
|
75
|
+
call.reject("No current activity to launch sign-in from")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
val parameters = com.microsoft.identity.client.AcquireTokenParameters.Builder()
|
|
80
|
+
.startAuthorizationFromActivity(activity)
|
|
81
|
+
.withScopes(scopes)
|
|
82
|
+
.withCallback(object : AuthenticationCallback {
|
|
83
|
+
override fun onSuccess(authenticationResult: IAuthenticationResult) {
|
|
84
|
+
call.resolve(toResultJson(authenticationResult))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun onError(exception: MsalException) {
|
|
88
|
+
call.reject(exception.message, exception.errorCode, exception)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override fun onCancel() {
|
|
92
|
+
call.reject("User cancelled sign-in", "USER_CANCELLED")
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.build()
|
|
96
|
+
|
|
97
|
+
application.acquireToken(parameters)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@PluginMethod
|
|
101
|
+
fun acquireTokenSilent(call: PluginCall) {
|
|
102
|
+
val requestedScopes = call.getArray("scopes")?.toList<String>() ?: emptyList()
|
|
103
|
+
val scopes = requestedScopes.ifEmpty { listOf("openid") }
|
|
104
|
+
val application = pca
|
|
105
|
+
if (application == null) {
|
|
106
|
+
call.reject("MSAL not initialized yet, try again shortly", "NOT_INITIALIZED")
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
application.getAccounts(object : IPublicClientApplication.LoadAccountsCallback {
|
|
111
|
+
override fun onTaskCompleted(accounts: MutableList<IAccount>) {
|
|
112
|
+
val account = accounts.firstOrNull()
|
|
113
|
+
if (account == null) {
|
|
114
|
+
call.reject("No cached account, call signIn() first", "UI_REQUIRED")
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
val silentParameters = com.microsoft.identity.client.AcquireTokenSilentParameters.Builder()
|
|
119
|
+
.forAccount(account)
|
|
120
|
+
.fromAuthority(account.authority)
|
|
121
|
+
.withScopes(scopes)
|
|
122
|
+
.withCallback(object : SilentAuthenticationCallback {
|
|
123
|
+
override fun onSuccess(authenticationResult: IAuthenticationResult) {
|
|
124
|
+
call.resolve(toResultJson(authenticationResult))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
override fun onError(exception: MsalException) {
|
|
128
|
+
if (exception is MsalUiRequiredException) {
|
|
129
|
+
call.reject("Interactive sign-in required", "UI_REQUIRED", exception)
|
|
130
|
+
} else {
|
|
131
|
+
call.reject(exception.message, "SILENT_FAILED", exception)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.build()
|
|
136
|
+
|
|
137
|
+
application.acquireTokenSilentAsync(silentParameters)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override fun onError(exception: MsalException) {
|
|
141
|
+
call.reject(exception.message, "SILENT_FAILED", exception)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@PluginMethod
|
|
147
|
+
fun getAccount(call: PluginCall) {
|
|
148
|
+
val application = pca ?: run {
|
|
149
|
+
call.resolve(JSObject().put("value", null))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
application.getAccounts(object : IPublicClientApplication.LoadAccountsCallback {
|
|
153
|
+
override fun onTaskCompleted(accounts: MutableList<IAccount>) {
|
|
154
|
+
val account = accounts.firstOrNull()
|
|
155
|
+
if (account == null) {
|
|
156
|
+
call.resolve(JSObject().put("value", null))
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
val result = JSObject()
|
|
160
|
+
result.put("accountId", account.id)
|
|
161
|
+
result.put("username", account.username)
|
|
162
|
+
val claims = JSObject()
|
|
163
|
+
account.claims?.forEach { (k, v) -> claims.put(k, claimValueToJson(v)) }
|
|
164
|
+
result.put("idTokenClaims", claims)
|
|
165
|
+
val wrapper = JSObject()
|
|
166
|
+
wrapper.put("value", result)
|
|
167
|
+
call.resolve(wrapper)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override fun onError(exception: MsalException) {
|
|
171
|
+
call.reject(exception.message, "GET_ACCOUNT_FAILED", exception)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@PluginMethod
|
|
177
|
+
fun signOut(call: PluginCall) {
|
|
178
|
+
val application = pca ?: run {
|
|
179
|
+
call.resolve()
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
application.getAccounts(object : IPublicClientApplication.LoadAccountsCallback {
|
|
183
|
+
override fun onTaskCompleted(accounts: MutableList<IAccount>) {
|
|
184
|
+
if (accounts.isEmpty()) {
|
|
185
|
+
call.resolve()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
// Remove every cached account, not just the first - MSAL's
|
|
189
|
+
// encrypted cache can accumulate more than one account over
|
|
190
|
+
// time, and signOut() must guarantee no account remains
|
|
191
|
+
// afterward, not just remove whichever one happens to be
|
|
192
|
+
// first in the list. Callbacks land on the main thread (MSAL
|
|
193
|
+
// Android convention), so plain vars are safe here without
|
|
194
|
+
// atomics.
|
|
195
|
+
var remaining = accounts.size
|
|
196
|
+
var failed = false
|
|
197
|
+
for (account in accounts) {
|
|
198
|
+
application.removeAccount(
|
|
199
|
+
account,
|
|
200
|
+
object : IMultipleAccountPublicClientApplication.RemoveAccountCallback {
|
|
201
|
+
override fun onRemoved() {
|
|
202
|
+
remaining--
|
|
203
|
+
if (remaining == 0 && !failed) {
|
|
204
|
+
call.resolve()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
override fun onError(exception: MsalException) {
|
|
209
|
+
if (!failed) {
|
|
210
|
+
failed = true
|
|
211
|
+
call.reject(exception.message, "SIGN_OUT_FAILED", exception)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
override fun onError(exception: MsalException) {
|
|
220
|
+
call.reject(exception.message, "SIGN_OUT_FAILED", exception)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fun toResultJson(result: IAuthenticationResult): JSObject {
|
|
226
|
+
val json = JSObject()
|
|
227
|
+
json.put("accessToken", result.accessToken)
|
|
228
|
+
// IAccount extends IClaimable, whose getIdToken() returns the raw JWT id_token
|
|
229
|
+
// directly - IAuthenticationResult itself has no idToken accessor, and the
|
|
230
|
+
// claims map (IClaimable.getClaims()) holds *decoded* claims, not the raw JWT
|
|
231
|
+
// under an "id_token" key.
|
|
232
|
+
json.put("idToken", result.account.idToken ?: "")
|
|
233
|
+
json.put("expiresOn", result.expiresOn.time)
|
|
234
|
+
json.put("accountId", result.account.id)
|
|
235
|
+
json.put("scopes", JSArray(result.scope.toList()))
|
|
236
|
+
return json
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private inline fun <reified T> JSArray.toList(): List<T> {
|
|
240
|
+
val out = mutableListOf<T>()
|
|
241
|
+
for (i in 0 until this.length()) {
|
|
242
|
+
out.add(this.get(i) as T)
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// account.claims (IClaimable.getClaims()) is a Map<String, ?> decoded from JSON:
|
|
248
|
+
// non-string claims (amr, groups, roles, nested objects) come through as List/Map,
|
|
249
|
+
// not String - a plain toString() would mangle them instead of preserving their
|
|
250
|
+
// shape as real JSON arrays/objects for the JS side.
|
|
251
|
+
private fun claimValueToJson(value: Any?): Any {
|
|
252
|
+
return when (value) {
|
|
253
|
+
null -> JSONObject.NULL
|
|
254
|
+
is String, is Boolean, is Int, is Long, is Double, is Float -> value
|
|
255
|
+
is Map<*, *> -> {
|
|
256
|
+
val obj = JSObject()
|
|
257
|
+
value.forEach { (k, v) -> obj.put(k.toString(), claimValueToJson(v)) }
|
|
258
|
+
obj
|
|
259
|
+
}
|
|
260
|
+
is Collection<*> -> {
|
|
261
|
+
val arr = JSArray()
|
|
262
|
+
value.forEach { arr.put(claimValueToJson(it)) }
|
|
263
|
+
arr
|
|
264
|
+
}
|
|
265
|
+
is Array<*> -> {
|
|
266
|
+
val arr = JSArray()
|
|
267
|
+
value.forEach { arr.put(claimValueToJson(it)) }
|
|
268
|
+
arr
|
|
269
|
+
}
|
|
270
|
+
else -> value.toString()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"client_id": "<ANDROID_APP_CLIENT_ID>",
|
|
3
|
+
"authorization_user_agent": "DEFAULT",
|
|
4
|
+
"redirect_uri": "msauth://com.example.myapp/<BASE64_URL_ENCODED_SIGNATURE_HASH>",
|
|
5
|
+
"account_mode": "MULTIPLE",
|
|
6
|
+
"broker_redirect_uri_registered": false,
|
|
7
|
+
"authorities": [
|
|
8
|
+
{
|
|
9
|
+
"type": "CIAM",
|
|
10
|
+
"default": true,
|
|
11
|
+
"authority_url": "https://<your-tenant-subdomain>.ciamlogin.com/<your-tenant-subdomain>.onmicrosoft.com/"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface AcquireTokenOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Delegated scopes to request, e.g. ['api://<api-client-id>/access_as_user'].
|
|
4
|
+
* Do NOT include 'openid', 'profile', or 'offline_access' - MSAL adds these
|
|
5
|
+
* reserved scopes to every request automatically. On iOS, explicitly
|
|
6
|
+
* specifying them is rejected outright; pass an empty array for a basic
|
|
7
|
+
* sign-in that only needs an ID token. On Android, MSAL's
|
|
8
|
+
* TokenParameters.withScopes() throws on an empty list (no equivalent
|
|
9
|
+
* "reserved scopes" carve-out), so the native Android plugin substitutes
|
|
10
|
+
* 'openid' when this is empty - callers on both platforms can still just
|
|
11
|
+
* pass [] for a basic sign-in.
|
|
12
|
+
*/
|
|
13
|
+
scopes: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Optional `domain_hint` sent to the Entra External ID authorize endpoint
|
|
16
|
+
* as an extra query parameter, on iOS and web. When the tenant's user flow
|
|
17
|
+
* has a social identity provider configured, this skips the Entra
|
|
18
|
+
* account-picker page and routes the user straight to that provider's own
|
|
19
|
+
* login screen. For social/federated IdPs this is Microsoft's "issuer
|
|
20
|
+
* acceleration" mechanism and takes a bare provider name, NOT a domain -
|
|
21
|
+
* use exactly 'Google' (capital G - case-sensitive, 'google' fails with
|
|
22
|
+
* AADSTS90023), 'facebook', or 'apple'. ('<domain>.com' values are a
|
|
23
|
+
* *different* mechanism reserved for custom SAML/WS-Fed IdPs matched by a
|
|
24
|
+
* configured domain - passing e.g. 'apple.com' here can misroute to an
|
|
25
|
+
* unrelated domain-based lookup instead of Sign in with Apple.) For a
|
|
26
|
+
* custom OIDC provider, use the domain segment of its issuer URI instead
|
|
27
|
+
* (e.g. 'www.linkedin.com'). Has no effect on `acquireTokenSilent()`.
|
|
28
|
+
* Ignored on Android (see the plugin README's Known gaps).
|
|
29
|
+
*/
|
|
30
|
+
domainHint?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Optional `prompt` sent to the Entra External ID authorize endpoint,
|
|
33
|
+
* currently **web only**. Indicates the type of user interaction
|
|
34
|
+
* required - the value most relevant here is `'create'`, which sends the
|
|
35
|
+
* user straight to the tenant's account-creation form instead of the
|
|
36
|
+
* sign-in form (requires the user flow to be a "Sign up and sign in"
|
|
37
|
+
* flow). Other values MSAL recognizes: `'login'` (force credential entry,
|
|
38
|
+
* skip SSO), `'none'` (silent-only, rejects with an error if interaction
|
|
39
|
+
* would be needed), `'consent'`, `'select_account'`.
|
|
40
|
+
*
|
|
41
|
+
* Caveat: unlike `domainHint`, `prompt: 'create'` has been reported as
|
|
42
|
+
* unreliable on some tenants (Microsoft Q&A has multiple reports of it
|
|
43
|
+
* working, then silently stopping) - treat it as best-effort, e.g. for an
|
|
44
|
+
* explicit "Create account" button next to "Sign in", not as something to
|
|
45
|
+
* design a flow around depending on. Has no effect on
|
|
46
|
+
* `acquireTokenSilent()`. Ignored on iOS/Android for now - native support
|
|
47
|
+
* would mirror how `domainHint` was added to `MSALInteractiveTokenParameters`
|
|
48
|
+
* (iOS) if/when needed.
|
|
49
|
+
*/
|
|
50
|
+
prompt?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface AcquireTokenResult {
|
|
53
|
+
accessToken: string;
|
|
54
|
+
idToken: string;
|
|
55
|
+
expiresOn: number;
|
|
56
|
+
/** stable per-user identifier, maps to the token's oid claim */
|
|
57
|
+
accountId: string;
|
|
58
|
+
scopes: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface AccountInfo {
|
|
61
|
+
accountId: string;
|
|
62
|
+
username: string | null;
|
|
63
|
+
idTokenClaims: Record<string, unknown> | null;
|
|
64
|
+
}
|
|
65
|
+
export interface SignOutOptions {
|
|
66
|
+
/** if true, also clears the Entra browser session, not just local cache */
|
|
67
|
+
clearBrowserSession?: boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface MsalAuthPlugin {
|
|
70
|
+
/**
|
|
71
|
+
* Interactive sign-in/sign-up. Launches the platform browser
|
|
72
|
+
* (ASWebAuthenticationSession / Chrome Custom Tabs via the native MSAL SDK)
|
|
73
|
+
* against the Entra External ID user flow configured in msal_config.json.
|
|
74
|
+
*/
|
|
75
|
+
signIn(options: AcquireTokenOptions): Promise<AcquireTokenResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Silent token acquisition using the cached account + refresh token.
|
|
78
|
+
* Rejects with code 'UI_REQUIRED' if silent renewal fails and an
|
|
79
|
+
* interactive signIn() is needed (e.g. revoked consent, expired session).
|
|
80
|
+
*/
|
|
81
|
+
acquireTokenSilent(options: AcquireTokenOptions): Promise<AcquireTokenResult>;
|
|
82
|
+
/**
|
|
83
|
+
* Returns the currently cached account, if any, without a network call.
|
|
84
|
+
* Wrapped in `{ value }` (matching Capacitor's own `Preferences.get()`
|
|
85
|
+
* convention) because the native bridge can only resolve a call with an
|
|
86
|
+
* object, never a bare `null` - unwrap `.value` at the call site.
|
|
87
|
+
*/
|
|
88
|
+
getAccount(): Promise<{
|
|
89
|
+
value: AccountInfo | null;
|
|
90
|
+
}>;
|
|
91
|
+
/** Clears the local token cache, and optionally the browser session. */
|
|
92
|
+
signOut(options?: SignOutOptions): Promise<void>;
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
import type { MsalAuthPlugin, AcquireTokenOptions, AcquireTokenResult, AccountInfo, SignOutOptions } from './definitions';
|
|
3
|
+
/**
|
|
4
|
+
* Web/PWA implementation backed by `@azure/msal-browser`, kept behind the
|
|
5
|
+
* same `MsalAuthPlugin` contract the native iOS/Android implementations use
|
|
6
|
+
* so callers (see `home.page.ts`, `auth.interceptor.ts`) don't need
|
|
7
|
+
* platform-specific branches.
|
|
8
|
+
*
|
|
9
|
+
* Interactive sign-in/sign-out use msal-browser's *redirect* APIs
|
|
10
|
+
* (`loginRedirect`/`logoutRedirect`), not popups. Microsoft's own MSAL.js
|
|
11
|
+
* guidance is that popups are "unreliable in Safari and mobile browsers due
|
|
12
|
+
* to popup blocker and gesture constraints," while redirect is "the most
|
|
13
|
+
* reliable interaction type across browsers, including Safari and mobile
|
|
14
|
+
* browsers" - the deciding factor for a PWA target.
|
|
15
|
+
*
|
|
16
|
+
* The tradeoff: `loginRedirect()`/`logoutRedirect()` navigate the browser
|
|
17
|
+
* away before their promises would otherwise resolve, so `signIn()` and
|
|
18
|
+
* `signOut({ clearBrowserSession: true })` below deliberately never resolve
|
|
19
|
+
* on web - unlike native, where the OS browser overlay returns control to
|
|
20
|
+
* the same JS context. The signed-in result is instead recovered on the
|
|
21
|
+
* *next* page load, once `handleRedirectPromise()` has processed it during
|
|
22
|
+
* initialization (see `createInstance()`). Callers must not block UI on
|
|
23
|
+
* `signIn()`'s promise settling on web; they should instead call
|
|
24
|
+
* `getAccount()` on app startup to pick up a just-completed redirect.
|
|
25
|
+
*/
|
|
26
|
+
export declare class MsalAuthWeb extends WebPlugin implements MsalAuthPlugin {
|
|
27
|
+
private initPromise;
|
|
28
|
+
signIn(options: AcquireTokenOptions): Promise<AcquireTokenResult>;
|
|
29
|
+
acquireTokenSilent(options: AcquireTokenOptions): Promise<AcquireTokenResult>;
|
|
30
|
+
getAccount(): Promise<{
|
|
31
|
+
value: AccountInfo | null;
|
|
32
|
+
}>;
|
|
33
|
+
signOut(options?: SignOutOptions): Promise<void>;
|
|
34
|
+
private getInstance;
|
|
35
|
+
private createInstance;
|
|
36
|
+
private loadConfig;
|
|
37
|
+
private toResult;
|
|
38
|
+
private toAccountInfo;
|
|
39
|
+
private rejection;
|
|
40
|
+
private messageOf;
|
|
41
|
+
}
|
package/dist/esm/web.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
import { BrowserCacheLocation, InteractionRequiredAuthError, createStandardPublicClientApplication, } from '@azure/msal-browser';
|
|
3
|
+
const CONFIG_PATH = 'assets/msal_config.json';
|
|
4
|
+
/**
|
|
5
|
+
* Web/PWA implementation backed by `@azure/msal-browser`, kept behind the
|
|
6
|
+
* same `MsalAuthPlugin` contract the native iOS/Android implementations use
|
|
7
|
+
* so callers (see `home.page.ts`, `auth.interceptor.ts`) don't need
|
|
8
|
+
* platform-specific branches.
|
|
9
|
+
*
|
|
10
|
+
* Interactive sign-in/sign-out use msal-browser's *redirect* APIs
|
|
11
|
+
* (`loginRedirect`/`logoutRedirect`), not popups. Microsoft's own MSAL.js
|
|
12
|
+
* guidance is that popups are "unreliable in Safari and mobile browsers due
|
|
13
|
+
* to popup blocker and gesture constraints," while redirect is "the most
|
|
14
|
+
* reliable interaction type across browsers, including Safari and mobile
|
|
15
|
+
* browsers" - the deciding factor for a PWA target.
|
|
16
|
+
*
|
|
17
|
+
* The tradeoff: `loginRedirect()`/`logoutRedirect()` navigate the browser
|
|
18
|
+
* away before their promises would otherwise resolve, so `signIn()` and
|
|
19
|
+
* `signOut({ clearBrowserSession: true })` below deliberately never resolve
|
|
20
|
+
* on web - unlike native, where the OS browser overlay returns control to
|
|
21
|
+
* the same JS context. The signed-in result is instead recovered on the
|
|
22
|
+
* *next* page load, once `handleRedirectPromise()` has processed it during
|
|
23
|
+
* initialization (see `createInstance()`). Callers must not block UI on
|
|
24
|
+
* `signIn()`'s promise settling on web; they should instead call
|
|
25
|
+
* `getAccount()` on app startup to pick up a just-completed redirect.
|
|
26
|
+
*/
|
|
27
|
+
export class MsalAuthWeb extends WebPlugin {
|
|
28
|
+
constructor() {
|
|
29
|
+
super(...arguments);
|
|
30
|
+
this.initPromise = null;
|
|
31
|
+
}
|
|
32
|
+
async signIn(options) {
|
|
33
|
+
var _a;
|
|
34
|
+
const pca = await this.getInstance();
|
|
35
|
+
try {
|
|
36
|
+
await pca.loginRedirect({
|
|
37
|
+
scopes: (_a = options.scopes) !== null && _a !== void 0 ? _a : [],
|
|
38
|
+
domainHint: options.domainHint,
|
|
39
|
+
prompt: options.prompt,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw this.rejection('SIGN_IN_FAILED', this.messageOf(error));
|
|
44
|
+
}
|
|
45
|
+
// Unreachable in practice: loginRedirect() above navigates the page away
|
|
46
|
+
// first. Nothing after this point runs in this JS context.
|
|
47
|
+
return new Promise(() => undefined);
|
|
48
|
+
}
|
|
49
|
+
async acquireTokenSilent(options) {
|
|
50
|
+
var _a, _b;
|
|
51
|
+
const pca = await this.getInstance();
|
|
52
|
+
const account = (_a = pca.getActiveAccount()) !== null && _a !== void 0 ? _a : pca.getAllAccounts()[0];
|
|
53
|
+
if (!account) {
|
|
54
|
+
throw this.rejection('UI_REQUIRED', 'No cached account - call signIn() first.');
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const result = await pca.acquireTokenSilent({ account, scopes: (_b = options.scopes) !== null && _b !== void 0 ? _b : [] });
|
|
58
|
+
return this.toResult(result);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof InteractionRequiredAuthError) {
|
|
62
|
+
throw this.rejection('UI_REQUIRED', error.message);
|
|
63
|
+
}
|
|
64
|
+
throw this.rejection('SILENT_FAILED', this.messageOf(error));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async getAccount() {
|
|
68
|
+
var _a, _b;
|
|
69
|
+
const pca = await this.getInstance();
|
|
70
|
+
const account = (_b = (_a = pca.getActiveAccount()) !== null && _a !== void 0 ? _a : pca.getAllAccounts()[0]) !== null && _b !== void 0 ? _b : null;
|
|
71
|
+
return { value: account ? this.toAccountInfo(account) : null };
|
|
72
|
+
}
|
|
73
|
+
async signOut(options) {
|
|
74
|
+
var _a, _b;
|
|
75
|
+
const pca = await this.getInstance();
|
|
76
|
+
const account = (_b = (_a = pca.getActiveAccount()) !== null && _a !== void 0 ? _a : pca.getAllAccounts()[0]) !== null && _b !== void 0 ? _b : undefined;
|
|
77
|
+
try {
|
|
78
|
+
if (options === null || options === void 0 ? void 0 : options.clearBrowserSession) {
|
|
79
|
+
// Ends the Entra browser SSO session too, not just the local cache -
|
|
80
|
+
// like signIn() above, this navigates away and never returns here.
|
|
81
|
+
await pca.logoutRedirect({ account });
|
|
82
|
+
return new Promise(() => undefined);
|
|
83
|
+
}
|
|
84
|
+
// Local-only sign-out: clears this account's cached tokens without any
|
|
85
|
+
// network call or navigation, matching native signOut()'s default
|
|
86
|
+
// "local cache only" behavior.
|
|
87
|
+
await pca.clearCache({ account });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw this.rejection('SIGN_OUT_FAILED', this.messageOf(error));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async getInstance() {
|
|
94
|
+
if (!this.initPromise) {
|
|
95
|
+
// Memoize per page load, but let a failed attempt be retried instead
|
|
96
|
+
// of permanently poisoning every later call with the same rejection.
|
|
97
|
+
this.initPromise = this.createInstance().catch((error) => {
|
|
98
|
+
this.initPromise = null;
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return this.initPromise;
|
|
103
|
+
}
|
|
104
|
+
async createInstance() {
|
|
105
|
+
var _a, _b, _c;
|
|
106
|
+
const config = await this.loadConfig();
|
|
107
|
+
const redirectUri = (_a = config.redirect_uri) !== null && _a !== void 0 ? _a : window.location.origin;
|
|
108
|
+
const pca = await createStandardPublicClientApplication({
|
|
109
|
+
auth: {
|
|
110
|
+
clientId: config.client_id,
|
|
111
|
+
authority: config.authority_url,
|
|
112
|
+
knownAuthorities: (_b = config.known_authorities) !== null && _b !== void 0 ? _b : [],
|
|
113
|
+
redirectUri,
|
|
114
|
+
postLogoutRedirectUri: (_c = config.post_logout_redirect_uri) !== null && _c !== void 0 ? _c : redirectUri,
|
|
115
|
+
},
|
|
116
|
+
cache: {
|
|
117
|
+
// localStorage (not the sessionStorage default) so a signed-in user
|
|
118
|
+
// stays signed in across app relaunches/tab closes, matching native
|
|
119
|
+
// MSAL's persistent Keychain/EncryptedSharedPreferences cache - the
|
|
120
|
+
// PWA equivalent of "not native" would otherwise re-prompt every
|
|
121
|
+
// fresh tab, unlike iOS/Android.
|
|
122
|
+
cacheLocation: BrowserCacheLocation.LocalStorage,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// Must run exactly once per page load, before any other account-reading
|
|
126
|
+
// call - this is what completes the loginRedirect()/logoutRedirect()
|
|
127
|
+
// round trip from the previous page load, if one is in flight. A failure
|
|
128
|
+
// here (e.g. the user denied consent on the Entra page) shouldn't break
|
|
129
|
+
// the whole plugin instance - it's swallowed so getAccount() simply comes
|
|
130
|
+
// back null and the app can prompt sign-in again, mirroring how a
|
|
131
|
+
// rejected native signIn() call leaves the app just as unauthenticated.
|
|
132
|
+
try {
|
|
133
|
+
const redirectResult = await pca.handleRedirectPromise();
|
|
134
|
+
if (redirectResult === null || redirectResult === void 0 ? void 0 : redirectResult.account) {
|
|
135
|
+
pca.setActiveAccount(redirectResult.account);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
console.warn('[MsalAuthWeb] handleRedirectPromise() failed', error);
|
|
140
|
+
}
|
|
141
|
+
return pca;
|
|
142
|
+
}
|
|
143
|
+
async loadConfig() {
|
|
144
|
+
const help = `Add a web msal_config.json at "${CONFIG_PATH}" (e.g. src/assets/msal_config.json in an Ionic/Angular app) - see the plugin README's "Web / PWA setup" section.`;
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await fetch(CONFIG_PATH);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw this.rejection('NOT_INITIALIZED', `Could not load ${CONFIG_PATH}: ${this.messageOf(error)}. ${help}`);
|
|
151
|
+
}
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw this.rejection('NOT_INITIALIZED', `${CONFIG_PATH} returned HTTP ${response.status}. ${help}`);
|
|
154
|
+
}
|
|
155
|
+
const config = (await response.json());
|
|
156
|
+
if (!config.client_id || !config.authority_url) {
|
|
157
|
+
throw this.rejection('NOT_INITIALIZED', `${CONFIG_PATH} is missing client_id or authority_url. ${help}`);
|
|
158
|
+
}
|
|
159
|
+
return config;
|
|
160
|
+
}
|
|
161
|
+
toResult(result) {
|
|
162
|
+
var _a, _b;
|
|
163
|
+
return {
|
|
164
|
+
accessToken: result.accessToken,
|
|
165
|
+
idToken: result.idToken,
|
|
166
|
+
expiresOn: result.expiresOn ? result.expiresOn.getTime() : Date.now(),
|
|
167
|
+
// Local MSAL account identifier, not the raw `oid` claim - matches the
|
|
168
|
+
// native implementations' accountId semantics (see definitions.ts).
|
|
169
|
+
accountId: (_b = (_a = result.account) === null || _a === void 0 ? void 0 : _a.homeAccountId) !== null && _b !== void 0 ? _b : '',
|
|
170
|
+
scopes: result.scopes,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
toAccountInfo(account) {
|
|
174
|
+
var _a, _b;
|
|
175
|
+
return {
|
|
176
|
+
accountId: account.homeAccountId,
|
|
177
|
+
username: (_a = account.username) !== null && _a !== void 0 ? _a : null,
|
|
178
|
+
idTokenClaims: (_b = account.idTokenClaims) !== null && _b !== void 0 ? _b : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
rejection(code, message) {
|
|
182
|
+
return Object.assign(new Error(message), { code });
|
|
183
|
+
}
|
|
184
|
+
messageOf(error) {
|
|
185
|
+
return error instanceof Error ? error.message : String(error);
|
|
186
|
+
}
|
|
187
|
+
}
|