@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.
@@ -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,4 @@
1
+ import type { MsalAuthPlugin } from './definitions';
2
+ declare const MsalAuth: MsalAuthPlugin;
3
+ export * from './definitions';
4
+ export { MsalAuth };
@@ -0,0 +1,6 @@
1
+ import { registerPlugin } from '@capacitor/core';
2
+ const MsalAuth = registerPlugin('MsalAuth', {
3
+ web: () => import('./web').then((m) => new m.MsalAuthWeb()),
4
+ });
5
+ export * from './definitions';
6
+ export { MsalAuth };
@@ -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
+ }
@@ -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
+ }