Skip to content

Commit

Permalink
Merge pull request #33 from Authress/graceful-handle-window-null
Browse files Browse the repository at this point in the history
Handle window is null more gracefully.
  • Loading branch information
wparad authored Jan 29, 2024
2 parents dec829e + 313e4ef commit c2f9919
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 69 deletions.
18 changes: 8 additions & 10 deletions src/extensionClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const base64url = require('./base64url');

const jwtManager = require('./jwtManager');
const { sanitizeUrl } = require('./util');
const windowManager = require('./windowManager');

const AuthenticationRequestNonceKey = 'ExtensionRequestNonce';

Expand All @@ -27,9 +28,9 @@ class ExtensionClient {
this.authressCustomDomain = sanitizeUrl(authressCustomDomain);
this.accessToken = null;

window.onload = async () => {
windowManager.onLoad(async () => {
await this.requestToken({ silent: true });
};
});
}

/**
Expand Down Expand Up @@ -76,7 +77,7 @@ class ExtensionClient {
}

async requestTokenContinuation(options = { code: null, silent: false }) {
const code = options && options.code || new URLSearchParams(window.location.search).get('code');
const code = options && options.code || new URLSearchParams(windowManager.getCurrentLocation().search).get('code');
if (!code) {
if (!options || !options.silent) {
const e = Error('OAuth Authorization code is required');
Expand Down Expand Up @@ -104,7 +105,7 @@ class ExtensionClient {
const tokenResponse = await result.json();
this.accessToken = tokenResponse.access_token;

const newUrl = new URL(window.location);
const newUrl = new URL(windowManager.getCurrentLocation());
newUrl.searchParams.delete('code');
newUrl.searchParams.delete('iss');
newUrl.searchParams.delete('nonce');
Expand Down Expand Up @@ -132,18 +133,15 @@ class ExtensionClient {
}
const url = new URL(this.authressCustomDomain);

const codeVerifier = base64url.encode((window.crypto || window.msCrypto).getRandomValues(new Uint32Array(16)).toString());
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
const hashBuffer = await (window.crypto || window.msCrypto).subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
const codeChallenge = base64url.encode(hashBuffer);
const { codeVerifier, codeChallenge } = jwtManager.getAuthCodes();

const redirectUrl = redirectUrlOverride || window.location.href;
const redirectUrl = redirectUrlOverride || windowManager.getCurrentLocation().href;
localStorage.setItem(AuthenticationRequestNonceKey, JSON.stringify({ codeVerifier, redirectUrl }));
url.searchParams.set('client_id', this.extensionId);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('redirect_uri', redirectUrl);
window.location.assign(url.toString());
windowManager.assign(url.toString());

// Prevent the current UI from taking any action once we decided we need to log in.
await new Promise(resolve => setTimeout(resolve, 5000));
Expand Down
3 changes: 2 additions & 1 deletion src/httpClient.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { sanitizeUrl } = require('./util');
const windowManager = require('./windowManager');

const defaultHeaders = {
'Content-Type': 'application/json'
Expand Down Expand Up @@ -90,7 +91,7 @@ class HttpClient {
if (data) {
request.body = JSON.stringify(data);
}
if (window.location.hostname !== 'localhost' && !!withCredentials) {
if (!windowManager.isLocalHost() && !!withCredentials) {
request.credentials = 'include';
}
const response = await fetch(url, request);
Expand Down
65 changes: 29 additions & 36 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const cookieManager = require('cookie');
const take = require('lodash.take');

const windowManager = require('./windowManager');
const HttpClient = require('./httpClient');
const jwtManager = require('./jwtManager');
const { sanitizeUrl } = require('./util');
Expand Down Expand Up @@ -34,38 +35,30 @@ class LoginClient {
this.httpClient = new HttpClient(this.hostUrl, this.logger);
this.lastSessionCheck = 0;

this.enableCredentials = this.getMatchingDomainInfo(this.hostUrl, typeof window !== 'undefined' ? window : undefined);
this.enableCredentials = this.getMatchingDomainInfo(this.hostUrl);

if (!settings.skipBackgroundCredentialsCheck) {
window.onload = async () => {
windowManager.onLoad(async () => {
await this.userSessionExists(true);
};
});
}
}

isLocalHost() {
const isLocalHost = typeof window !== 'undefined' && window.location && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
return isLocalHost;
}

getMatchingDomainInfo(hostUrlString, webWindow) {
getMatchingDomainInfo(hostUrlString) {
const hostUrl = new URL(hostUrlString);

if (this.isLocalHost()) {
return false;
}

if (typeof webWindow === 'undefined') {
if (windowManager.isLocalHost()) {
return false;
}

if (webWindow.location.protocol !== 'https:') {
const currentLocation = windowManager.getCurrentLocation();
if (currentLocation.protocol !== 'https:') {
return false;
}

const tokenUrlList = hostUrl.host.toLowerCase().split('.').reverse();
// Login url may not be known all the time, in which case we will compare the token url to the appUrl
const appUrlList = webWindow.location.host.toLowerCase().split('.').reverse();
const appUrlList = currentLocation.host.toLowerCase().split('.').reverse();

let reversedMatchSegments = [];
for (let segment of tokenUrlList) {
Expand Down Expand Up @@ -170,8 +163,8 @@ class LoginClient {
const userConfigurationScreenUrl = new URL('/settings', this.hostUrl);
userConfigurationScreenUrl.searchParams.set('client_id', this.settings.applicationId);
userConfigurationScreenUrl.searchParams.set('start_page', options && options.startPage || 'Profile');
userConfigurationScreenUrl.searchParams.set('redirect_uri', options && options.redirectUrl || window.location.href);
window.location.assign(userConfigurationScreenUrl.toString());
userConfigurationScreenUrl.searchParams.set('redirect_uri', options && options.redirectUrl || windowManager.getCurrentLocation().href);
windowManager.assign(userConfigurationScreenUrl.toString());
await Promise.resolve();
}

Expand Down Expand Up @@ -280,8 +273,8 @@ class LoginClient {
}

async userSessionContinuation(backgroundTrigger) {
const urlSearchParams = new URLSearchParams(window.location.search);
const newUrl = new URL(window.location);
const urlSearchParams = new URLSearchParams(windowManager.getCurrentLocation().search);
const newUrl = new URL(windowManager.getCurrentLocation());

let authRequest = {};
try {
Expand Down Expand Up @@ -330,7 +323,7 @@ class LoginClient {
}
}

if (this.isLocalHost()) {
if (windowManager.isLocalHost()) {
if (urlSearchParams.get('nonce') && urlSearchParams.get('access_token')) {
newUrl.searchParams.delete('iss');
newUrl.searchParams.delete('nonce');
Expand Down Expand Up @@ -361,7 +354,7 @@ class LoginClient {
return true;
}

if (!this.isLocalHost() && !backgroundTrigger) {
if (!windowManager.isLocalHost() && !backgroundTrigger) {
try {
const sessionResult = await this.httpClient.patch('/session', this.enableCredentials, {}, null, true);
// In the case that the session contains non cookie based data, store it back to the cookie for this domain
Expand Down Expand Up @@ -403,7 +396,7 @@ class LoginClient {
throw e;
}

const urlSearchParams = new URLSearchParams(window.location.search);
const urlSearchParams = new URLSearchParams(windowManager.getCurrentLocation().search);
const authenticationRequestId = state || urlSearchParams.get('state');
if (!authenticationRequestId) {
const e = Error('The `state` parameters must be specified to update this authentication request');
Expand All @@ -416,7 +409,7 @@ class LoginClient {
connectionId, tenantLookupIdentifier, connectionProperties
});

window.location.assign(requestOptions.data.authenticationUrl);
windowManager.assign(requestOptions.data.authenticationUrl);
} catch (error) {
this.logger && this.logger.log && this.logger.log({ title: 'Failed to update extension authentication request', error });
if (error.status && error.status >= 400 && error.status < 500) {
Expand Down Expand Up @@ -460,7 +453,7 @@ class LoginClient {
}
}

const headers = this.enableCredentials && !this.isLocalHost() ? {} : {
const headers = this.enableCredentials && !windowManager.isLocalHost() ? {} : {
Authorization: `Bearer ${accessToken}`
};

Expand Down Expand Up @@ -513,8 +506,8 @@ class LoginClient {

try {
const normalizedRedirectUrl = redirectUrl && new URL(redirectUrl).toString();
const selectedRedirectUrl = normalizedRedirectUrl || window.location.href;
const headers = this.enableCredentials && !this.isLocalHost() ? {} : {
const selectedRedirectUrl = normalizedRedirectUrl || windowManager.getCurrentLocation().href;
const headers = this.enableCredentials && !windowManager.isLocalHost() ? {} : {
Authorization: `Bearer ${accessToken}`
};
const requestOptions = await this.httpClient.post('/authentication', this.enableCredentials, {
Expand All @@ -524,7 +517,7 @@ class LoginClient {
connectionProperties,
applicationId: this.settings.applicationId
}, headers);
window.location.assign(requestOptions.data.authenticationUrl);
windowManager.assign(requestOptions.data.authenticationUrl);
} catch (error) {
this.logger && this.logger.log && this.logger.log({ title: 'Failed to start user identity link', error });
if (error.status && error.status >= 400 && error.status < 500) {
Expand Down Expand Up @@ -569,7 +562,7 @@ class LoginClient {

try {
const normalizedRedirectUrl = redirectUrl && new URL(redirectUrl).toString();
const selectedRedirectUrl = normalizedRedirectUrl || window.location.href;
const selectedRedirectUrl = normalizedRedirectUrl || windowManager.getCurrentLocation().href;
if (clearUserDataBeforeLogin !== false) {
userIdentityTokenStorageManager.clear();
}
Expand All @@ -586,12 +579,12 @@ class LoginClient {
enableCredentials: authResponse.data.enableCredentials, multiAccount
}));
if (openType === 'tab') {
const result = window.open(authResponse.data.authenticationUrl, '_blank');
const result = windowManager.open(authResponse.data.authenticationUrl, '_blank');
if (!result || result.closed || typeof result.closed === 'undefined') {
window.location.assign(authResponse.data.authenticationUrl);
windowManager.assign(authResponse.data.authenticationUrl);
}
} else {
window.location.assign(authResponse.data.authenticationUrl);
windowManager.assign(authResponse.data.authenticationUrl);
}
} catch (error) {
this.logger && this.logger.log && this.logger.log({ title: 'Failed to start authentication for user', error });
Expand Down Expand Up @@ -642,17 +635,17 @@ class LoginClient {
if (this.enableCredentials) {
try {
await this.httpClient.delete('/session', this.enableCredentials);
if (redirectUrl && redirectUrl !== window.location.href) {
window.location.assign(redirectUrl);
if (redirectUrl && redirectUrl !== windowManager.getCurrentLocation().href) {
windowManager.assign(redirectUrl);
}
return;
} catch (error) { /**/ }
}

const fullLogoutUrl = new URL('/logout', this.hostUrl);
fullLogoutUrl.searchParams.set('redirect_uri', redirectUrl || window.location.href);
fullLogoutUrl.searchParams.set('redirect_uri', redirectUrl || windowManager.getCurrentLocation().href);
fullLogoutUrl.searchParams.set('client_id', this.settings.applicationId);
window.location.assign(fullLogoutUrl.toString());
windowManager.assign(fullLogoutUrl.toString());
}
}

Expand Down
32 changes: 32 additions & 0 deletions src/windowManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class WindowManager {
onLoad(callback) {
if (typeof window !== 'undefined') {
window.onload = callback;
}
}

isLocalHost() {
const isLocalHost = typeof window !== 'undefined' && window.location && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
return isLocalHost;
}

getCurrentLocation() {
return typeof window !== 'undefined' && new URL(window.location) || new URL('http://localhost:8080');
}

assign(newLocationUrl) {
if (typeof window === 'undefined') {
return null;
}
return window.location.assign(newLocationUrl.toString());
}

open(newLocationUrl) {
if (typeof window === 'undefined') {
return null;
}
return window.location.open(newLocationUrl.toString());
}
}

module.exports = new WindowManager();
57 changes: 35 additions & 22 deletions tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const sinon = require('sinon');
const { expect } = require('chai');

const { LoginClient } = require('../src/index');
const windowManager = require('../src/windowManager');

let sandbox;
beforeEach(() => { sandbox = sinon.createSandbox(); });
Expand Down Expand Up @@ -51,14 +52,20 @@ describe('index.js', () => {
};
};
for (let test of tests) {
// eslint-disable-next-line no-loop-func
it(test.name, () => {
const windowManagerMock = sandbox.mock(windowManager);
windowManagerMock.expects('onLoad').exactly(test.expectedError ? 0 : 1);

try {
const loginClient = new LoginClient({ authressApiUrl: test.url, skipBackgroundCredentialsCheck: true });
const loginClient = new LoginClient({ authressApiUrl: test.url });
expect(loginClient.httpClient.loginUrl).to.eql(test.expectedBaseUrl);
expect(test.expectedError).to.eql(undefined);
} catch (error) {
expect(error.message).to.eql(test.expectedError, `The test was not supposed to throw an error, but it did: ${error.message}`);
}

windowManagerMock.verify();
});
}
});
Expand All @@ -68,39 +75,45 @@ describe('index.js', () => {
it('Adjacent domain returns true', () => {
const authressApiUrl = 'https://security.application.com';
const loginClient = new LoginClient({ authressApiUrl, skipBackgroundCredentialsCheck: true });
const window = {
location: {
protocol: 'https:',
host: 'app.application.com'
}
};
const result = loginClient.getMatchingDomainInfo(authressApiUrl, window);

const windowManagerMock = sandbox.mock(windowManager);
windowManagerMock.expects('onLoad').exactly(0);
windowManagerMock.expects('getCurrentLocation').exactly(1).returns({
protocol: 'https:',
host: 'app.application.com'
});
const result = loginClient.getMatchingDomainInfo(authressApiUrl);
windowManagerMock.verify();
expect(result).to.eql(true);
});

it('Top level domain returns true', () => {
const authressApiUrl = 'https://security.application.com';
const loginClient = new LoginClient({ authressApiUrl, skipBackgroundCredentialsCheck: true });
const window = {
location: {
protocol: 'https:',
host: 'application.com'
}
};
const result = loginClient.getMatchingDomainInfo(authressApiUrl, window);

const windowManagerMock = sandbox.mock(windowManager);
windowManagerMock.expects('onLoad').exactly(0);
windowManagerMock.expects('getCurrentLocation').exactly(1).returns({
protocol: 'https:',
host: 'application.com'
});
const result = loginClient.getMatchingDomainInfo(authressApiUrl);
windowManagerMock.verify();
expect(result).to.eql(true);
});

it('Cross domain returns false', () => {
const authressApiUrl = 'https://security.application.com';
const loginClient = new LoginClient({ authressApiUrl, skipBackgroundCredentialsCheck: true });
const window = {
location: {
protocol: 'https:',
host: 'app.cross-domain.com'
}
};
const result = loginClient.getMatchingDomainInfo(authressApiUrl, window);

const windowManagerMock = sandbox.mock(windowManager);
windowManagerMock.expects('onLoad').exactly(0);
windowManagerMock.expects('getCurrentLocation').exactly(1).returns({
protocol: 'https:',
host: 'app.cross-domain.com'
});
const result = loginClient.getMatchingDomainInfo(authressApiUrl);
windowManagerMock.verify();
expect(result).to.eql(false);
});
});
Expand Down

0 comments on commit c2f9919

Please sign in to comment.