PageSourceSearch

https://smrtbld.ai/js/graph/auth.js

js smrtbld.ai collected 2026-10-05 15:08:54 UTC 8,959 bytes, 259 lines download raw bytes

1/**
2 * Microsoft 365 Authentication — MSAL v5.5 (clean build).
3 *
4 * Uses the UMD build loaded via <script> tag (window.msal).
5 * Single PublicClientApplication instance, popup auth flow,
6 * incremental consent via per-feature scopes.
7 */
8
9import {
10    getClientId, buildMsalConfig, loginScopes, allScopes, getPopupRedirectUri,
11} from './msal-config.js';
12import { connectedServicesApi } from '../api/connected-services.js';
13
14const { PublicClientApplication, InteractionRequiredAuthError, BrowserAuthError } = window.msal;
15
16let msalClient = null;
17let _initialized = false;
18let _account = null;
19
20export const msGraph = {
21
22    /**
23     * Initialize MSAL client. Safe to call multiple times.
24     * @returns {boolean} true if ready, false if MS 365 not configured for this tenant
25     */
26    async init() {
27        if (_initialized && msalClient) return true;
28
29        const clientId = getClientId();
30        if (!clientId) {
31            console.warn('[MS Graph] No client ID — Microsoft 365 not configured for this tenant');
32            return false;
33        }
34
35        try {
36            msalClient = new PublicClientApplication(buildMsalConfig(clientId));
37            await msalClient.initialize();
38
39            // Process any stale redirect state (insurance for popup edge cases)
40            await msalClient.handleRedirectPromise();
41
42            _initialized = true;
43
44            // Restore active account from cache (survives refresh via sessionStorage)
45            const accounts = msalClient.getAllAccounts();
46            if (accounts.length > 0) {
47                _account = accounts[0];
48                msalClient.setActiveAccount(_account);
49            }
50
51            return true;
52        } catch (err) {
53            console.error('[MS Graph] MSAL init failed:', err);
54            return false;
55        }
56    },
57
58    /**
59     * Sign in via popup. Returns the user profile or null.
60     */
61    async signIn() {
62        if (!await this.init()) return null;
63
64        try {
65            const result = await msalClient.loginPopup({
66                scopes: loginScopes,
67                prompt: 'select_account',
68                redirectUri: getPopupRedirectUri(),
69            });
70
71            _account = result.account;
72            msalClient.setActiveAccount(_account);
73
74            // Fetch profile from Graph
75            const user = await this._fetchUserProfile();
76
77            // Save connection to SmartBuild backend
78            await connectedServicesApi.connectMicrosoft({
79                serviceId: user.id,
80                principalName: user.userPrincipalName,
81            });
82
83            return user;
84        } catch (err) {
85            if (err instanceof BrowserAuthError) {
86                if (err.errorCode === 'user_cancelled') return null;
87                if (err.errorCode === 'popup_window_error') {
88                    throw new Error('Popup blocked — please allow popups for this site and try again');
89                }
90                if (err.errorCode === 'interaction_in_progress') {
91                    throw new Error('Another sign-in is already in progress');
92                }
93            }
94            console.error('[MS Graph] Sign-in failed:', err);
95            throw err;
96        }
97    },
98
99    /**
100     * Sign out — clear MSAL session + backend connection.
101     */
102    async signOut() {
103        try {
104            if (msalClient && _account) {
105                await msalClient.logoutPopup({
106                    account: _account,
107                    postLogoutRedirectUri: getPopupRedirectUri(),
108                });
109            }
110        } catch (err) {
111            console.warn('[MS Graph] Popup logout failed, clearing locally:', err);
112        }
113
114        // Always clean up local state
115        _account = null;
116        if (msalClient) msalClient.setActiveAccount(null);
117
118        try {
119            await connectedServicesApi.disconnectMicrosoft();
120        } catch {
121            // Best effort — don't break the UI
122        }
123    },
124
125    /**
126     * Get an access token (silent first, popup fallback).
127     * Supports incremental consent — pass specific scopes per feature.
128     * @param {string[]} [scopes] — Defaults to allScopes
129     * @returns {string|null} Access token or null if not connected
130     */
131    async getToken(scopes) {
132        if (!_initialized || !msalClient) return null;
133
134        // Resolve account from MSAL cache
135        if (!_account) {
136            _account = msalClient.getActiveAccount() || msalClient.getAllAccounts()[0] || null;
137            if (!_account) return null;
138        }
139
140        const requestScopes = scopes || allScopes;
141
142        try {
143            const result = await msalClient.acquireTokenSilent({
144                scopes: requestScopes,
145                account: _account,
146            });
147            return result.accessToken;
148        } catch (silentError) {
149            if (silentError instanceof InteractionRequiredAuthError) {
150                try {
151                    const result = await msalClient.acquireTokenPopup({
152                        scopes: requestScopes,
153                        account: _account,
154                        redirectUri: getPopupRedirectUri(),
155                    });
156                    return result.accessToken;
157                } catch (popupError) {
158                    if (popupError instanceof BrowserAuthError) {
159                        if (popupError.errorCode === 'user_cancelled') return null;
160                        if (popupError.errorCode === 'popup_window_error') {
161                            console.error('[MS Graph] Popup blocked during token acquisition');
162                            return null;
163                        }
164                    }
165                    console.error('[MS Graph] Token popup failed:', popupError);
166                    return null;
167                }
168            }
169
170            console.warn('[MS Graph] Silent token failed:', silentError.message);
171            return null;
172        }
173    },
174
175    /**
176     * Get an access token SILENTLY — never shows a popup.
177     *
178     * Use this for anything the user didn't just click: background/event-driven work such as
179     * fetching a Teams message after a SignalR push, or renewing a Graph subscription on app
180     * load. getToken() falls back to acquireTokenPopup(), which would throw a login dialog at
181     * an unattended browser; callers here must degrade gracefully on null instead.
182     *
183     * @param {string[]} [scopes] — Defaults to allScopes
184     * @returns {Promise<string|null>} Access token, or null if it can't be had without interaction
185     */
186    async getTokenSilent(scopes) {
187        if (!_initialized || !msalClient) return null;
188
189        if (!_account) {
190            _account = msalClient.getActiveAccount() || msalClient.getAllAccounts()[0] || null;
191            if (!_account) return null;
192        }
193
194        try {
195            const result = await msalClient.acquireTokenSilent({
196                scopes: scopes || allScopes,
197                account: _account,
198            });
199            return result.accessToken;
200        } catch (silentError) {
201            // Includes InteractionRequiredAuthError — deliberately NOT escalated to a popup.
202            console.warn('[MS Graph] Silent-only token failed:', silentError.message);
203            return null;
204        }
205    },
206
207    /**
208     * Check if connected to Microsoft 365.
209     * No popups — checks local MSAL cache only.
210     * @returns {{ connected: boolean, email: string|null }}
211     */
212    async checkConnection() {
213        await this.init();
214
215        if (_initialized && msalClient) {
216            const account = msalClient.getActiveAccount() || msalClient.getAllAccounts()[0];
217            if (account) {
218                _account = account;
219                return { connected: true, email: account.username };
220            }
221        }
222
223        return { connected: false, email: null };
224    },
225
226    /**
227     * Get the connected email address (quick, no API call).
228     */
229    getConnectedEmail() {
230        return _account?.username
231            || msalClient?.getActiveAccount()?.username
232            || null;
233    },
234
235    /**
236     * Get the signed-in account's Entra tenant id (quick, no API call).
237     * Needed for the `teamworkUserIdentity` body Graph wants on chat read-state writes —
238     * see teams.markChatReadForUser.
239     */
240    getTenantId() {
241        return _account?.tenantId
242            || msalClient?.getActiveAccount()?.tenantId
243            || null;
244    },
245
246    /**
247     * Whether MSAL is initialized and a client ID is available.
248     */
249    isAvailable() {
250        return _initialized && msalClient !== null;
251    },
252
253    // ─── Internal ────────────────────────────────────────
254
255    async _fetchUserProfile() {
256        const { graphGet } = await import('./graph-client.js');
257        return graphGet('/me?$select=id,displayName,mail,userPrincipalName');
258    },
259};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.