blob: 90b3815ae7c31770e3dead9648a31bfbd1642fd7 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/**
* @fileoverview Handles auth requests to Nacl.
*/
var getOrigin = require('./util').getOrigin;
module.exports = AuthHandler;
function AuthHandler(channel) {
if (!(this instanceof AuthHandler)) {
return new AuthHandler(channel);
}
this._channel = channel;
// Map from origins to the Vanadium app ports of tabs with active requests.
// This keeps track of existing auth tabs for an origin so new ones are not
// started.
this._outstandingAuthRequests = {};
// Map from caveat tab id to origin.
this._caveatTabOrigins = {};
// Handle tabs being closed.
chrome.tabs.onRemoved.addListener(this.onRemovedTab.bind(this));
}
AuthHandler.prototype.onRemovedTab = function(tabId) {
var origin = this._caveatTabOrigins[tabId];
if (!origin) {
return; // Not one of the caveat tabs.
}
delete this._caveatTabOrigins[tabId];
if (origin in this._outstandingAuthRequests) {
this.finishAuth({
origin: origin,
cancel: true,
});
}
};
// Get an access token from the chrome.identity API.
// See https://developer.chrome.com/apps/app_identity
AuthHandler.prototype.getAccessToken = function(cb) {
if (process.env.TEST_ACCESS_TOKEN) {
return process.nextTick(cb.bind(null, null, process.env.TEST_ACCESS_TOKEN));
}
// This will return an access token for the profile that the user is
// signed in to chrome as. If the user is not signed in to chrome, an
// OAuth window will pop up and ask them to sign in.
//
// For now, we don't have a way to ask the user which profile they would
// like to use. However, once the `chrome.identity.getAccounts` API call
// gets out of dev/beta channel, we can get a list of accounts and prompt
// the user for which one they would like to use with vanadium.
chrome.identity.getAuthToken({
interactive: true
}, function(token) {
// Wrap in process.nextTick so chrome stack traces can use sourceMap.
process.nextTick(function(){
if (chrome.runtime.lastError) {
console.error('Error getting auth token.', chrome.runtime.lastError);
return cb(chrome.runtime.lastError);
}
return cb(null, token);
});
});
};
// Get the name of all accounts from wspr. Will be empty if no root account
// exists.
AuthHandler.prototype.getAccounts = function(cb) {
this._channel.performRpc('auth:get-accounts', {}, cb);
};
// Get an access token from the user and use it to create the root account on
// wspr.
AuthHandler.prototype.createAccount = function(cb) {
var ah = this;
this.getAccessToken(function(err, token) {
if (err) {
return cb(err);
}
// If getAccessToken returns an empty token we shouldn't send it to the
// identity server. Instead we directly pass an error to the callback which
// will purge the cache and try again.
if (!token) {
return cb(new Error('getAccessToken returned an empty token.'), null,
token);
}
ah._channel.performRpc('auth:create-account', {token: token},
function(err, createdAccount) {
cb(err, createdAccount, token);
});
});
};
// Check if an origin is already associated with an account on wspr.
AuthHandler.prototype.originHasAccount = function(origin, cb) {
this._channel.performRpc('auth:origin-has-account', {origin: origin}, cb);
};
// Associate the account with the origin on wspr.
AuthHandler.prototype.associateAccount =
function(account, origin, caveats, cb) {
this._channel.performRpc('auth:associate-account', {
account: account,
origin: origin,
caveats: caveats
}, cb);
};
// Pop up a new tab asking the user to chose their caveats.
AuthHandler.prototype.getCaveats = function(account, origin, appPort) {
// Store the account name on the appPort.
appPort.account = account;
var outstandingAuthRequests = this._outstandingAuthRequests;
var caveatTabOrigins = this._caveatTabOrigins;
if (origin in this._outstandingAuthRequests) {
outstandingAuthRequests[origin].push(appPort);
// Switch to the corresponding open caveat tab.
for (var tabId in caveatTabOrigins) {
if (caveatTabOrigins[tabId] === origin) {
var tabIdAsNumber = +tabId;
chrome.tabs.update(tabIdAsNumber, {active: true});
break;
}
}
return;
}
outstandingAuthRequests[origin] = [appPort];
// Get currently active tab in the window.
var windowId = appPort.sender.tab.windowId;
chrome.tabs.query({active: true, windowId: windowId}, function(tabs) {
// Store the current tab id so we can switch back to it after the addcaveats
// tab is removed. Note that the currently active tab might not be the same
// as the tab that is requesting authentication.
if (tabs && tabs[0] && tabs[0].id) {
appPort.currentTabId = tabs[0].id;
}
chrome.tabs.create({
url: chrome.extension.getURL('html/addcaveats.html') + '?origin=' +
encodeURIComponent(origin)
}, function(tab) {
caveatTabOrigins[tab.id] = origin;
});
});
};
// Handle incoming 'auth' message.
AuthHandler.prototype.handleAuthMessage = function(appPort) {
appPort.postMessage({
type: 'auth:received'
});
var origin;
try {
origin = getOrigin(appPort.sender.url);
} catch (err) {
return sendErrorToContentScript('auth', appPort, err);
}
var ah = this;
this.getAccounts(function(err, accounts) {
if (err) {
return sendErrorToContentScript('auth', appPort, err);
}
if (!accounts || accounts.length === 0) {
// No account exists. Create one and then call getCaveats.
var retry = true;
var createAccountCallback = function(err, createdAccount, token) {
if (err) {
// If the token we received from chrome.identity.getAuthToken failed
// to authenticate for the identity server, it may because that the
// cached token has expired.
// So, we remove the token from the cache and retry once.
// TODO(suharshs,nlacasse): Filter by a more specific set of errors.
if (retry && token) {
retry = false;
return chrome.identity.removeCachedAuthToken({
'token': token
}, function(){
ah.createAccount(createAccountCallback);
});
} else if (retry) {
// If the token was not returned from getAuthToken, force the logout
// of the user and try again.
// This usually happens when a user has changed their password from
// a different browser instance and the current browser isn't logged
// into their new account.
return chrome.identity.launchWebAuthFlow({
'url': 'https://accounts.google.com/logout'
}, function(tokenUrl) {
ah.createdAccount(createAccountCallback);
});
}
return sendErrorToContentScript('auth', appPort, err);
}
ah.getCaveats(createdAccount, origin, appPort);
};
ah.createAccount(createAccountCallback);
} else {
// At least one account already exists. Use the first one.
var account = accounts[0];
// Check if origin is associated.
ah.originHasAccount(origin, function(err, hasAccount) {
if (err) {
return sendErrorToContentScript('auth', appPort, err);
}
if (hasAccount) {
// Origin already associated. Return success.
appPort.postMessage({
type: 'auth:success',
account: account
});
} else {
// No origin associated. Get caveats and then associate.
ah.getCaveats(account, origin, appPort);
}
});
}
});
};
AuthHandler.prototype.handleFinishAuth = function(caveatsPort, msg) {
if (caveatsPort.sender.url.indexOf(chrome.extension.getURL(
'html/addcaveats.html')) !== 0) {
console.error('invalid requester for associateAccount:finish');
return;
}
this.finishAuth(msg);
// Close the caveats tab.
// Note: This triggers a call onRemovedTab().
chrome.tabs.remove(caveatsPort.sender.tab.id);
};
AuthHandler.prototype.finishAuth = function(msg) {
var appPorts = this._outstandingAuthRequests[msg.origin];
delete this._outstandingAuthRequests[msg.origin];
if (!Array.isArray(appPorts)) {
console.error('Finish auth flow request received for unknown origin');
return;
}
var self = this;
appPorts.forEach(function(appPort) {
if (msg.origin !== getOrigin(appPort.sender.url)) {
console.error('Invalid origin.');
return;
}
if (msg.cancel) {
return sendErrorToContentScript('auth', appPort,
new Error('User declined to bless origin.'));
}
if (msg.origin !== getOrigin(appPort.sender.url)) {
return sendErrorToContentScript('auth', appPort,
new Error('Invalid origin.'));
}
if (!appPort.account) {
return sendErrorToContentScript('auth', appPort,
new Error('No port.account.'));
}
if (!msg.caveats || msg.caveats.length === 0) {
return sendErrorToContentScript('auth', appPort,
new Error('No caveats selected'));
}
self.associateAccount(appPort.account, msg.origin, msg.caveats,
function(err) {
if (err) {
return sendErrorToContentScript('auth', appPort, err);
}
appPort.postMessage({
type: 'auth:success',
account: appPort.account
});
});
});
};
// Convert an Error object into a bare Object with the same properties. We do
// this because port.postMessage calls JSON.stringify, which ignores the message
// and stack properties on Error objects.
function errorToObject(err) {
var obj = {};
Object.getOwnPropertyNames(err).forEach(function(key) {
obj[key] = err[key];
});
return obj;
}
// Helper functions to send error message back to calling content script.
function sendErrorToContentScript(type, port, err) {
console.error(err);
port.postMessage({
type: type + ':error',
error: errorToObject(err)
});
}