Skip to content

Commit b111b63

Browse files
committed
Simplify login popup creation flow
The process of creating and navigating the login popup used to involve two steps, first creating a blank window and then navigating it to the final authorization URL. This was needed because, in Firefox, the popup window had to be created in the same turn of the event loop as the user's click on the "Log in" button (otherwise the popup blocker would trigger) but generating the authorization URL involved an async "fetch" of API links. The major browsers have now all settled on a more flexible model for allowing popups in response to user gestures, where the popup must be opened within a certain time window of the gesture. In practice the timeout seems to be ~1000ms in Safari and longer than that in other browsers. In this context we expect the async delay between the user clicking the "Log in" button and us creating the popup to be ~0ms, since the API links should already have been fetched at this point and so we're just fetching locally cached values. Based on this assumption, the flow for creating the login popup window has been simplified to create the popup window at the final URL immediately, removing the need to open a blank window as a first step. Simplifying the code here will make it easier to change how the popup window and sidebar communicate, eg. to resolve an issue with the new Cross-Origin-Opener-Policy header [1]. [1] https://github.com/hypothesis/product-backlog/issues/1333
1 parent 14a1cb7 commit b111b63

File tree

4 files changed

+72
-97
lines changed

4 files changed

+72
-97
lines changed

src/sidebar/services/auth.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,15 @@ export class AuthService extends TinyEmitter {
270270
* then exchange for access and refresh tokens.
271271
*/
272272
async function login() {
273-
const authWindow = OAuthClient.openAuthPopupWindow($window);
273+
// Any async steps before the call to `client.authorize` must complete
274+
// in less than ~1 second, otherwise the browser's popup blocker may block
275+
// the popup.
276+
//
277+
// `oauthClient` is async in case in needs to fetch links from the API.
278+
// This should already have happened by the time this function is called
279+
// however, so it will just be returning a cached value.
274280
const client = await oauthClient();
275-
const code = await client.authorize($window, authWindow);
281+
const code = await client.authorize($window);
276282

277283
// Save the auth code. It will be exchanged for an access token when the
278284
// next API request is made.

src/sidebar/services/test/auth-test.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ describe('AuthService', () => {
8282
fakeClient.config = config;
8383
return fakeClient;
8484
};
85-
FakeOAuthClient.openAuthPopupWindow = sinon.stub();
8685

8786
fakeWindow = new FakeWindow();
8887

@@ -530,12 +529,9 @@ describe('AuthService', () => {
530529
fakeSettings.services = [];
531530
});
532531

533-
it('calls OAuthClient#authorize', () => {
534-
const fakePopup = {};
535-
FakeOAuthClient.openAuthPopupWindow.returns(fakePopup);
536-
return auth.login().then(() => {
537-
assert.calledWith(fakeClient.authorize, fakeWindow, fakePopup);
538-
});
532+
it('calls OAuthClient#authorize', async () => {
533+
await auth.login();
534+
assert.calledWith(fakeClient.authorize, fakeWindow);
539535
});
540536

541537
it('resolves when auth completes successfully', () => {

src/sidebar/util/oauth-client.js

Lines changed: 24 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,9 @@ export class OAuthClient {
116116
* Returns an authorization code which can be passed to `exchangeAuthCode`.
117117
*
118118
* @param {Window} $window - Window which will receive the auth response.
119-
* @param {Window} authWindow - Popup window where the login prompt will be shown.
120-
* This should be created using `openAuthPopupWindow`.
121119
* @return {Promise<string>}
122120
*/
123-
authorize($window, authWindow) {
121+
authorize($window) {
124122
// Random state string used to check that auth messages came from the popup
125123
// window that we opened.
126124
//
@@ -160,10 +158,29 @@ export class OAuthClient {
160158
authURL.searchParams.set('response_type', 'code');
161159
authURL.searchParams.set('state', state);
162160

163-
// @ts-ignore - TS doesn't support `location = <string>`. We have to
164-
// use this method to set the URL rather than `location.href = <string>`
165-
// because `authWindow` is cross-origin.
166-
authWindow.location = authURL.toString();
161+
// In Chrome & Firefox the sizes passed to `window.open` are used for the
162+
// viewport size. In Safari the size is used for the window size including
163+
// title bar etc. There is enough vertical space at the bottom to allow for
164+
// this.
165+
//
166+
// See https://bugs.webkit.org/show_bug.cgi?id=143678
167+
const width = 475;
168+
const height = 430;
169+
const left = $window.screen.width / 2 - width / 2;
170+
const top = $window.screen.height / 2 - height / 2;
171+
172+
// Generate settings for `window.open` in the required comma-separated
173+
// key=value format.
174+
const authWindowSettings = `left=${left},top=${top},width=${width},height=${height}`;
175+
const authWindow = $window.open(
176+
authURL.toString(),
177+
'Log in to Hypothesis',
178+
authWindowSettings
179+
);
180+
181+
if (!authWindow) {
182+
throw new Error('Failed to open login window');
183+
}
167184

168185
return authResponse.then(rsp => rsp.code);
169186
}
@@ -220,43 +237,4 @@ export class OAuthClient {
220237
refreshToken: response.refresh_token,
221238
};
222239
}
223-
224-
/**
225-
* Create and show a pop-up window for use with `OAuthClient#authorize`.
226-
*
227-
* This function _must_ be called in the same turn of the event loop as the
228-
* button or link which initiates login to avoid triggering the popup blocker
229-
* in certain browsers. See https://github.com/hypothesis/client/issues/534
230-
* and https://github.com/hypothesis/client/issues/535.
231-
*
232-
* @param {Window} $window - The parent of the created window.
233-
* @return {Window} The new popup window.
234-
*/
235-
static openAuthPopupWindow($window) {
236-
// In Chrome & Firefox the sizes passed to `window.open` are used for the
237-
// viewport size. In Safari the size is used for the window size including
238-
// title bar etc. There is enough vertical space at the bottom to allow for
239-
// this.
240-
//
241-
// See https://bugs.webkit.org/show_bug.cgi?id=143678
242-
const width = 475;
243-
const height = 430;
244-
const left = $window.screen.width / 2 - width / 2;
245-
const top = $window.screen.height / 2 - height / 2;
246-
247-
// Generate settings for `window.open` in the required comma-separated
248-
// key=value format.
249-
const authWindowSettings = `left=${left},top=${top},width=${width},height=${height}`;
250-
const authWindow = $window.open(
251-
'about:blank',
252-
'Log in to Hypothesis',
253-
authWindowSettings
254-
);
255-
256-
if (!authWindow) {
257-
throw new Error('Failed to open login window');
258-
}
259-
260-
return authWindow;
261-
}
262240
}

src/sidebar/util/test/oauth-client-test.js

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -184,29 +184,6 @@ describe('sidebar/util/oauth-client', () => {
184184
});
185185
});
186186

187-
describe('#openAuthPopupWindow', () => {
188-
it('opens a popup window', () => {
189-
const fakeWindow = new FakeWindow();
190-
const popupWindow = OAuthClient.openAuthPopupWindow(fakeWindow);
191-
assert.equal(popupWindow, fakeWindow.open.returnValues[0]);
192-
assert.calledWith(
193-
fakeWindow.open,
194-
'about:blank',
195-
'Log in to Hypothesis',
196-
'left=274.5,top=169,width=475,height=430'
197-
);
198-
});
199-
200-
it('throws error if popup cannot be opened', () => {
201-
const fakeWindow = new FakeWindow();
202-
fakeWindow.open = sinon.stub().returns(null);
203-
204-
assert.throws(() => {
205-
OAuthClient.openAuthPopupWindow(fakeWindow);
206-
}, 'Failed to open login window');
207-
});
208-
});
209-
210187
describe('#authorize', () => {
211188
let fakeWindow;
212189

@@ -221,35 +198,53 @@ describe('sidebar/util/oauth-client', () => {
221198
});
222199

223200
function authorize() {
224-
const popupWindow = OAuthClient.openAuthPopupWindow(fakeWindow);
225-
const authorized = client.authorize(fakeWindow, popupWindow);
226-
return { authorized, popupWindow };
201+
return client.authorize(fakeWindow);
227202
}
228203

229-
it('navigates the popup window to the authorization URL', () => {
230-
const { authorized, popupWindow } = authorize();
204+
it('opens a popup window at the authorization URL', async () => {
205+
const authorized = authorize();
206+
207+
const params = new URLSearchParams({
208+
client_id: config.clientId,
209+
origin: 'https://client.hypothes.is',
210+
response_mode: 'web_message',
211+
response_type: 'code',
212+
state: 'notrandom',
213+
});
214+
const expectedAuthURL = `${config.authorizationEndpoint}?${params}`;
215+
216+
assert.calledWith(
217+
fakeWindow.open,
218+
expectedAuthURL,
219+
'Log in to Hypothesis',
220+
'left=274.5,top=169,width=475,height=430'
221+
);
231222

232223
fakeWindow.sendMessage({
233224
type: 'authorization_response',
234225
code: 'expected-code',
235226
state: 'notrandom',
236227
});
237228

238-
return authorized.then(() => {
239-
const params = new URLSearchParams({
240-
client_id: config.clientId,
241-
origin: 'https://client.hypothes.is',
242-
response_mode: 'web_message',
243-
response_type: 'code',
244-
state: 'notrandom',
245-
});
246-
const expectedAuthUrl = `${config.authorizationEndpoint}?${params}`;
247-
assert.equal(popupWindow.location.href, expectedAuthUrl);
248-
});
229+
await authorized;
230+
});
231+
232+
it('rejects if popup cannot be opened', async () => {
233+
fakeWindow.open = sinon.stub().returns(null);
234+
235+
let error;
236+
try {
237+
await authorize();
238+
} catch (e) {
239+
error = e;
240+
}
241+
242+
assert.instanceOf(error, Error);
243+
assert.equal(error.message, 'Failed to open login window');
249244
});
250245

251246
it('resolves with an auth code if successful', () => {
252-
const { authorized } = authorize();
247+
const authorized = authorize();
253248

254249
fakeWindow.sendMessage({
255250
type: 'authorization_response',
@@ -263,7 +258,7 @@ describe('sidebar/util/oauth-client', () => {
263258
});
264259

265260
it('rejects with an error if canceled', () => {
266-
const { authorized } = authorize();
261+
const authorized = authorize();
267262

268263
fakeWindow.sendMessage({
269264
type: 'authorization_canceled',
@@ -276,7 +271,7 @@ describe('sidebar/util/oauth-client', () => {
276271
});
277272

278273
it('ignores responses with incorrect "state" values', () => {
279-
const { authorized } = authorize();
274+
const authorized = authorize();
280275

281276
fakeWindow.sendMessage({
282277
type: 'authorization_response',

0 commit comments

Comments
 (0)