Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,10 @@ module.exports = {
files: ['tests/**/*-test.{js,ts}'],
extends: ['plugin:qunit/recommended'],
},
{
// inline scripts in static pages, such as public/redirect.html
files: ['public/**/*.html', 'tests/dummy/public/**/*.html'],
plugins: ['html'],
},
],
};
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ jobs:
- ember-lts-5.4
- ember-lts-5.8
- ember-lts-5.12
- ember-release
- ember-beta
- ember-canary
#- ember-release
#- ember-beta
#- ember-canary
- ember-default-with-jquery
- ember-classic
- embroider-safe
Expand Down
12 changes: 10 additions & 2 deletions addon/lib/random-url-safe.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
export default function randomUrlSafe(length) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Bytes at or above the largest multiple of chars.length are skipped so
// that every character is equally likely.
var limit = 256 - (256 % chars.length);
var result = '';

for (var i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
while (result.length < length) {
var bytes = crypto.getRandomValues(new Uint8Array(length));
for (var i = 0; i < bytes.length && result.length < length; i++) {
if (bytes[i] < limit) {
result += chars.charAt(bytes[i] % chars.length);
}
}
}

return result;
Expand Down
29 changes: 19 additions & 10 deletions addon/lib/uuid-generator.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
function toHex(bytes) {
return Array.from(bytes, function (b) {
return b.toString(16).padStart(2, '0');
}).join('');
}

var UUIDGenerator = {
// Returns a random (version 4) UUID. Uses crypto.getRandomValues rather
// than crypto.randomUUID, which is only available in secure contexts.
generate() {
var d = new Date().getTime();
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
/[xy]/g,
function (c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
}
);
return uuid;
var bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
var hex = toHex(bytes);
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
},
};

Expand Down
78 changes: 77 additions & 1 deletion addon/mixins/ui-service-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ function parseMessage(url, keys) {
return data;
}

// Parses a result message posted by redirect.html, which has the form
// '{"torii-popup:<id>":"<redirect url>"}'. Returns null for anything else,
// since other scripts on the page may also use postMessage.
function parseResultMessage(data) {
if (typeof data !== 'string') {
return null;
}
try {
const msg = JSON.parse(data);
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
return null;
}
const keys = Object.keys(msg);
if (keys.length !== 1 || typeof msg[keys[0]] !== 'string') {
return null;
}
return { key: decodeURIComponent(keys[0]), url: msg[keys[0]] };
} catch {
return null;
}
}

var ServicesMixin = Mixin.create({
init() {
this._super(...arguments);
Expand All @@ -32,12 +54,29 @@ var ServicesMixin = Mixin.create({
// // resolves with data.code, as from http://app.com?code=13124
// });
//
// Services that use this mixin should implement openRemote
// Services that use this mixin should implement openRemote, closeRemote
// and pollRemote, and override remoteWindow if this.remote is not the
// window that will post the result.
//
open(url, keys, options) {
let service = this;
let lastRemote = this.remote;
let storageToriiEventHandler;
let messageToriiEventHandler;
let trustedWindow;

// Only messages from the window we opened, on our own origin, are
// trusted. The window is remembered because close() clears
// service.remote, possibly before the result message is handled.
function isFromRemote(messageEvent) {
if (messageEvent.origin !== window.location.origin) {
return false;
}
if (!trustedWindow) {
trustedWindow = service.remoteWindow();
}
return !!messageEvent.source && messageEvent.source === trustedWindow;
}

return new EmberPromise(function (resolve, reject) {
if (lastRemote) {
Expand All @@ -55,11 +94,42 @@ var ServicesMixin = Mixin.create({
});
}
};

// Using postMessage as an alternative to localStorage/storageEvent
// for case of web site embedded in iframe
messageToriiEventHandler = function (messageEvent) {
if (!isFromRemote(messageEvent)) {
return;
}
if (messageEvent.data === 'getPendingRequestKey') {
messageEvent.source.postMessage(
JSON.stringify({ pendingRequestKey: service.pendingRequestKey }),
window.location.origin
);
return;
}
const result = parseResultMessage(messageEvent.data);
if (!result) {
return;
}
var remoteIdFromEvent = PopupIdSerializer.deserialize(result.key);
if (remoteId === remoteIdFromEvent) {
var data = parseMessage(result.url, keys);
localStorage.removeItem(result.key);
run(function () {
resolve(data);
});
}
};
window.addEventListener('message', messageToriiEventHandler);

var pendingRequestKey = PopupIdSerializer.serialize(remoteId);
service.pendingRequestKey = pendingRequestKey;
localStorage.setItem(CURRENT_REQUEST_KEY, pendingRequestKey);
localStorage.removeItem(WARNING_KEY);

service.openRemote(url, pendingRequestKey, options);
trustedWindow = service.remoteWindow();
service.schedulePolling();

var onbeforeunload = window.onbeforeunload;
Expand Down Expand Up @@ -115,10 +185,16 @@ var ServicesMixin = Mixin.create({
}).finally(function () {
// didClose will reject this same promise, but it has already resolved.
service.close();
window.removeEventListener('message', messageToriiEventHandler);
window.removeEventListener('storage', storageToriiEventHandler);
});
},

// The window expected to post the result message back to this one.
remoteWindow() {
return this.remote;
},

close() {
if (this.remote) {
this.closeRemote();
Expand Down
19 changes: 19 additions & 0 deletions addon/providers/-private/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ export function resetLoadScript() {
alternativeLoadScript = false;
}

// The response params to read from the redirect, plus `state` so it can be
// verified.
export function withState(responseParams) {
return responseParams.indexOf('state') === -1
? responseParams.concat('state')
: responseParams;
}

// A copy of `obj` with only the given keys.
export function pick(obj, keys) {
let result = {};
keys.forEach(function (key) {
if (key in obj) {
result[key] = obj[key];
}
});
return result;
}

export function loadScript(src) {
if (alternativeLoadScript) {
return alternativeLoadScript(src);
Expand Down
5 changes: 2 additions & 3 deletions addon/providers/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import EmberObject, { computed } from '@ember/object';
import requiredProperty from 'torii/lib/required-property';
import { getOwner } from 'torii/lib/container-utils';
import { configurable } from 'torii/configuration';
import configuration from 'torii/configuration';
import { configurable, getConfiguration } from 'torii/configuration';

var DEFAULT_REMOTE_SERVICE_NAME = 'popup';

Expand Down Expand Up @@ -31,7 +30,7 @@ var Base = EmberObject.extend({
var owner = getOwner(this);
var remoteServiceName =
this.get('configuredRemoteServiceName') ||
configuration.remoteServiceName ||
getConfiguration().remoteServiceName ||
DEFAULT_REMOTE_SERVICE_NAME;

return owner.lookup('torii-service:' + remoteServiceName);
Expand Down
54 changes: 49 additions & 5 deletions addon/providers/google-oauth2-bearer-v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Promise as EmberPromise } from 'rsvp';
import { run } from '@ember/runloop';
import OAuth2Code from 'torii/providers/oauth2-code';
import { configurable } from 'torii/configuration';
import { withState, pick } from './-private/utils';

/**
* This class implements a provider allowing authentication against google's
Expand Down Expand Up @@ -33,6 +34,14 @@ var GoogleOauth2BearerV2 = OAuth2Code.extend({

redirectUri: configurable('redirectUri'),

/**
* Whether to check that Google's response carries the `state` that was
* sent with the request.
*
* @property {boolean} verifyState
*/
verifyState: configurable('verifyState', true),

/**
* @method open
* @return {Promise<object>} If the authorization attempt is a success,
Expand All @@ -44,16 +53,25 @@ var GoogleOauth2BearerV2 = OAuth2Code.extend({
* closed the popup window, the promise rejects.
*/
open(options) {
var name = this.get('name'),
this.refreshState();
var provider = this,
name = this.get('name'),
url = this.buildUrl(),
redirectUri = this.get('redirectUri'),
responseParams = this.get('responseParams'),
tokenValidationUrl = this.get('tokenValidationUrl'),
clientId = this.get('apiKey');
clientId = this.get('apiKey'),
state = this.get('state'),
verifyState = this.get('verifyState');

return this.get('popup')
.open(url, responseParams, options)
.then(function (authData) {
.open(
url,
verifyState ? withState(responseParams) : responseParams,
options
)
.then(function (rawAuthData) {
var authData = pick(rawAuthData, responseParams);
var missingResponseParams = [];

responseParams.forEach(function (param) {
Expand All @@ -70,6 +88,10 @@ var GoogleOauth2BearerV2 = OAuth2Code.extend({
);
}

if (verifyState) {
provider.assertState(rawAuthData, state);
}

/* at this point 'authData' should look like:
{
access_token: '<some long acces token string>',
Expand All @@ -85,7 +107,29 @@ var GoogleOauth2BearerV2 = OAuth2Code.extend({
let xhr = new XMLHttpRequest();
xhr.overrideMimeType('application/json');
xhr.onload = function () {
var jsonResponse = JSON.parse(xhr.responseText);
if (xhr.status < 200 || xhr.status >= 300) {
run(() =>
reject(
new Error(
`Token validation request failed with status '${xhr.status}' (server '${tokenValidationUrl}' '${xhr.responseText}').`
)
)
);
return;
}
var jsonResponse;
try {
jsonResponse = JSON.parse(xhr.responseText) || {};
} catch (e) {
run(() =>
reject(
new Error(
`Token validation server returned invalid JSON (server '${tokenValidationUrl}').`
)
)
);
return;
}
/* the response is a JSON that looks like:
{
"audience":"8819981768.apps.googleusercontent.com",
Expand Down
31 changes: 27 additions & 4 deletions addon/providers/oauth2-bearer.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
/* eslint-disable ember/no-get */
import Provider from 'torii/providers/oauth2-code';
import { configurable } from 'torii/configuration';
import { withState, pick } from './-private/utils';

var Oauth2Bearer = Provider.extend({
responseType: 'token',

/**
* Whether to check that the provider's response carries the `state` that
* was sent with the request. Turn off only for providers that do not
* return `state`.
*
* @property {boolean} verifyState
*/
verifyState: configurable('verifyState', true),

/**
* @method open
* @return {Promise<object>} If the authorization attempt is a success,
Expand All @@ -15,13 +26,21 @@ var Oauth2Bearer = Provider.extend({
* closed the popup window, the promise rejects.
*/
open(options) {
var name = this.get('name'),
this.refreshState();
var provider = this,
name = this.get('name'),
url = this.buildUrl(),
redirectUri = this.get('redirectUri'),
responseParams = this.get('responseParams');
responseParams = this.get('responseParams'),
state = this.get('state'),
verifyState = this.get('verifyState');

return this.get('popup')
.open(url, responseParams, options)
.open(
url,
verifyState ? withState(responseParams) : responseParams,
options
)
.then(function (authData) {
var missingResponseParams = [];

Expand All @@ -39,8 +58,12 @@ var Oauth2Bearer = Provider.extend({
);
}

if (verifyState) {
provider.assertState(authData, state);
}

return {
authorizationToken: authData,
authorizationToken: pick(authData, responseParams),
provider: name,
redirectUri: redirectUri,
};
Expand Down
Loading
Loading