Repository navigation
Expand file tree
/
Copy pathauth.js
More file actions
244 lines (215 loc) · 11 KB
/
Copy pathauth.js
File metadata and controls
244 lines (215 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
// MSAL sign-in and Graph requests.
//
// Tokens refresh silently while the refresh token lasts. On an unmanaged PC that lease is short
// (8 of 24 hours), so a page left open over sleep comes back unable to refresh. Then nothing fails:
// every Graph request waits, a "Session expired" bar offers Sign in, and its click opens the
// popup (browsers block popups that no click opened). Once signed in, the waiting requests carry
// on, so the click that found the session expired still does what it was for.
import { config } from './config.js';
import { $, escHtml, showError } from './ui.js';
import { MOCK_ACCOUNT, mockEnabled, mockGraphFetch } from './mockgraph.js';
// Mock mode only: set to make every token request need interaction, as an expired session does.
export const MOCK_EXPIRED_KEY = 'mock_tc_session_expired';
const AUTHORITY = `https://login.microsoftonline.com/${config.authorityTenant}`;
// Registered redirect URIs (AADSTS50011 for any other). Popups and the hidden renewal iframe come
// back to an empty page whose opener or parent reads the response; a full-page redirect (when a
// popup is blocked) comes back to the login page, which completes it.
const AUTH_DONE_URI = new URL(config.authDonePagePath, window.location.href).href;
const LOGIN_URI = new URL(config.loginPagePath, window.location.href).href;
let msalInstance = null;
let currentAccount = null;
let initializePromise = null;
let reauth = null; // { promise, resolve } while a sign-in is awaited
export function getRuntimeConfig() {
return { ...config, authority: AUTHORITY, redirectUri: AUTH_DONE_URI };
}
export function getCurrentAccount() {
return currentAccount;
}
function setCurrentAccount(account) {
currentAccount = account || null;
if (msalInstance) msalInstance.setActiveAccount(currentAccount);
}
// Errors that only an interactive sign-in can fix: expired or revoked refresh tokens, missing
// consent, and the hidden-iframe renewal timing out (third-party cookies blocked).
function needsInteraction(error) {
if (error instanceof msal.InteractionRequiredAuthError) return true;
return /interaction_required|login_required|consent_required|no_tokens_found|no_account_error|token_refresh_required|monitor_window_timeout|block_iframe_reload|invalid_grant/i
.test(`${error?.errorCode || ''} ${error?.message || ''}`);
}
const popupBlocked = error => /popup_window_error|empty_window_error/.test(error?.errorCode || '');
const popupCancelled = error => /user_cancelled/.test(error?.errorCode || '');
async function initializeSessionInternal() {
if (mockEnabled) {
currentAccount = MOCK_ACCOUNT;
return { ready: true, authenticated: true, account: currentAccount };
}
if (!msalInstance) {
msalInstance = new msal.PublicClientApplication({
auth: { clientId: config.clientId, authority: AUTHORITY, redirectUri: AUTH_DONE_URI },
cache: { cacheLocation: 'sessionStorage' },
});
await msalInstance.initialize();
}
try {
const response = await msalInstance.handleRedirectPromise();
if (response?.account) setCurrentAccount(response.account);
} catch (error) {
showError('Sign-in redirect failed: ' + error.message);
}
setCurrentAccount(msalInstance.getActiveAccount() || msalInstance.getAllAccounts()[0]);
return { ready: true, authenticated: Boolean(currentAccount), account: currentAccount };
}
export function initializeSession() {
initializePromise ||= initializeSessionInternal().catch(error => {
initializePromise = null;
throw error;
});
return initializePromise;
}
export async function ensureSession({ redirectIfMissing = false } = {}) {
const session = await initializeSession();
if (!session.authenticated && redirectIfMissing) redirectTo(config.loginPagePath);
return session;
}
export async function requireAppSession() {
return (await ensureSession({ redirectIfMissing: true })).authenticated;
}
function redirectTo(path) {
const target = new URL(path, window.location.href);
if (target.pathname !== window.location.pathname) window.location.replace(target.href);
}
// The login page's button. A click, so the popup is allowed; a blocked popup falls back to a redirect.
export async function signIn({ navigateToApp = document.body.dataset.page === 'login' } = {}) {
await initializeSession();
if (!currentAccount) {
try {
setCurrentAccount((await msalInstance.loginPopup({ scopes: config.scopes })).account);
} catch (error) {
if (popupBlocked(error)) return msalInstance.loginRedirect({ scopes: config.scopes, redirectUri: LOGIN_URI });
if (!popupCancelled(error)) showError('Sign-in failed: ' + error.message);
throw error;
}
}
if (navigateToApp) redirectTo(config.appPagePath);
return currentAccount;
}
// Local only: forgets this app's tokens and account, and leaves the Microsoft session alone.
export async function signOut({ navigateToLogin = true } = {}) {
if (msalInstance) await msalInstance.clearCache();
setCurrentAccount(null);
if (navigateToLogin) redirectTo(config.loginPagePath);
}
// ── Expired session ─────────────────────────────────────────────────────────
const EXPIRED_MESSAGE = 'Your session expired, so what is shown may be out of date. Sign in to refresh it; what you were doing will finish.';
// Resolves once the user has signed in again through the bar.
function waitForSignIn() {
if (!reauth) {
let resolve;
reauth = { promise: new Promise(r => { resolve = r; }), resolve };
renderSessionBar(EXPIRED_MESSAGE);
document.body.classList.add('session-expired');
}
return reauth.promise;
}
function renderSessionBar(message) {
let bar = $('session-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'session-bar';
bar.setAttribute('role', 'alert');
document.body.prepend(bar);
}
bar.innerHTML = `<span>${escHtml(message)}</span><button type="button" class="btn btn-warn">Sign in</button>`;
// Called straight from the click with no await before it, so the popup counts as user-opened.
bar.querySelector('button').addEventListener('click', event => {
event.currentTarget.disabled = true; // one popup at a time; a failure redraws the bar
void signInAgain();
});
}
async function signInAgain() {
try {
if (mockEnabled) {
sessionStorage.removeItem(MOCK_EXPIRED_KEY);
} else {
const request = { scopes: config.scopes, account: currentAccount || undefined, loginHint: currentAccount?.username };
setCurrentAccount((await msalInstance.acquireTokenPopup(request)).account);
}
} catch (error) {
if (popupBlocked(error)) {
await msalInstance.acquireTokenRedirect({ scopes: config.scopes, loginHint: currentAccount?.username, redirectUri: LOGIN_URI });
return;
}
renderSessionBar(popupCancelled(error) ? EXPIRED_MESSAGE : `Sign-in failed: ${error.message}. Try again.`);
return;
}
$('session-bar')?.remove();
document.body.classList.remove('session-expired');
const { resolve } = reauth;
reauth = null;
resolve();
}
async function acquireToken({ forceRefresh = false } = {}) {
await initializeSession();
// Unbounded on purpose: each pass that finds no token waits for the user to sign in again,
// for as long as that takes. It spins only as fast as they click Sign in.
for (;;) {
if (mockEnabled) {
if (!sessionStorage.getItem(MOCK_EXPIRED_KEY)) return 'mock-token';
} else if (currentAccount) {
try {
// Locally, `sessionStorage.dev_no_iframe_renewal` stops renewal at the refresh token, as
// on an unmanaged PC, where the hidden-iframe renewal cannot get a token either.
const noIframe = location.hostname === 'localhost' && sessionStorage.getItem('dev_no_iframe_renewal');
const response = await msalInstance.acquireTokenSilent({
scopes: config.scopes, account: currentAccount, forceRefresh,
...(noIframe ? { cacheLookupPolicy: msal.CacheLookupPolicy.RefreshToken } : {}),
});
setCurrentAccount(response.account);
return response.accessToken;
} catch (error) {
if (!needsInteraction(error)) throw error;
}
}
await waitForSignIn();
forceRefresh = false;
}
}
// On returning to the tab (after sleep, say), find out now whether the session lasted, so the
// bar is already up before the user clicks anything.
function checkSessionOnReturn() {
if (document.hidden || !currentAccount || reauth) return;
acquireToken().catch(error => showError('Session check failed: ' + error.message));
}
// ── Graph ───────────────────────────────────────────────────────────────────
async function graphRequest(baseUrl, url, options, retryUnauthorized = true) {
const token = await acquireToken({ forceRefresh: !retryUnauthorized });
const fullUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
if (mockEnabled) return mockGraphFetch(fullUrl, options);
const response = await fetch(fullUrl, {
...options,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers },
});
if (response.ok) return response.status === 204 ? null : response.json();
const payload = await response.json().catch(() => null);
// A 401 can mean the token was revoked early: retry once with a freshly fetched one.
if (response.status === 401 && retryUnauthorized) return graphRequest(baseUrl, url, options, false);
const error = new Error(payload?.error?.message || `Graph API error ${response.status}`);
error.status = response.status;
error.code = payload?.error?.code || '';
throw error;
}
export const graphFetch = (url, options = {}) => graphRequest('https://graph.microsoft.com/v1.0', url, options);
export const graphFetchBeta = (url, options = {}) => graphRequest('https://graph.microsoft.com/beta', url, options);
// ── Page boot ───────────────────────────────────────────────────────────────
async function bootLoginPage() {
try {
if ((await ensureSession()).authenticated) { redirectTo(config.appPagePath); return; }
} catch (error) {
showError(error.message);
}
$('signin-screen').style.display = 'flex';
$('btn-signin').addEventListener('click', () => void signIn().catch(() => { }));
}
document.addEventListener('visibilitychange', checkSessionOnReturn);
if (document.body.dataset.page === 'login') void bootLoginPage();