669 lines
23 KiB
JavaScript
669 lines
23 KiB
JavaScript
////////////////////////////////////////////////////////////////////////////
|
|
//
|
|
// Copyright 2016 Realm Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
'use strict';
|
|
|
|
const AuthError = require('./errors').AuthError;
|
|
const permissionApis = require('./permission-api');
|
|
|
|
const merge = require('deepmerge');
|
|
const require_method = require;
|
|
const URL = require('url-parse');
|
|
|
|
const refreshTimers = {};
|
|
const retryInterval = 5 * 1000; // Amount of time between retrying authentication requests, if the first request failed.
|
|
const refreshBuffer = 20 * 1000; // A "safe" amount of time before a token expires that allow us to refresh it.
|
|
const refreshLowerBound = 10 * 1000; // Lower bound for refreshing tokens.
|
|
|
|
function node_require(module) {
|
|
return require_method(module);
|
|
}
|
|
|
|
function checkTypes(args, types) {
|
|
args = Array.prototype.slice.call(args);
|
|
for (var i = 0; i < types.length; ++i) {
|
|
if (args.length > i && typeof args[i] !== types[i]) {
|
|
throw new TypeError('param ' + i + ' must be of type ' + types[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkObjectTypes(obj, types) {
|
|
for (const name of Object.getOwnPropertyNames(types)) {
|
|
const actualType = typeof obj[name];
|
|
let targetType = types[name];
|
|
const isOptional = targetType[targetType.length - 1] === '?';
|
|
if (isOptional) {
|
|
targetType = targetType.slice(0, -1);
|
|
}
|
|
|
|
if (!isOptional && actualType === 'undefined') {
|
|
throw new Error(`${name} is required, but a value was not provided.`);
|
|
}
|
|
|
|
if (actualType !== targetType) {
|
|
throw new TypeError(`${name} must be of type '${targetType}' but was of type '${actualType}' instead.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Perform a HTTP request, enqueuing it if too many requests are already in
|
|
// progress to avoid hammering the server.
|
|
const performFetch = (function() {
|
|
const doFetch = typeof fetch === 'undefined' ? node_require('node-fetch') : fetch;
|
|
const queue = [];
|
|
let count = 0;
|
|
const maxCount = 5;
|
|
const next = () => {
|
|
if (count >= maxCount) {
|
|
return;
|
|
}
|
|
const req = queue.shift();
|
|
if (!req) {
|
|
return;
|
|
}
|
|
const [url, options, resolve, reject] = req;
|
|
++count;
|
|
// node doesn't support Promise.prototype.finally until 10
|
|
doFetch(url, options)
|
|
.then(response => {
|
|
--count;
|
|
next();
|
|
resolve(response);
|
|
})
|
|
.catch(error => {
|
|
--count;
|
|
next();
|
|
reject(error);
|
|
});
|
|
};
|
|
return (url, options) => {
|
|
return new Promise((resolve, reject) => {
|
|
queue.push([url, options, resolve, reject]);
|
|
next();
|
|
});
|
|
};
|
|
})();
|
|
|
|
const url_parse = require('url-parse');
|
|
|
|
const postHeaders = {
|
|
'content-type': 'application/json;charset=utf-8',
|
|
'accept': 'application/json'
|
|
};
|
|
|
|
function append_url(server, path) {
|
|
return server + (server.charAt(server.length - 1) != '/' ? '/' : '') + path;
|
|
}
|
|
|
|
function scheduleAccessTokenRefresh(user, localRealmPath, realmUrl, expirationDate) {
|
|
let userTimers = refreshTimers[user.identity];
|
|
if (!userTimers) {
|
|
refreshTimers[user.identity] = userTimers = {};
|
|
}
|
|
|
|
// We assume that access tokens have ~ the same expiration time, so if someone already
|
|
// scheduled a refresh, it's likely to complete before the one we would have scheduled
|
|
if (!userTimers[localRealmPath]) {
|
|
const timeout = Math.max(expirationDate - Date.now() - refreshBuffer, refreshLowerBound);
|
|
userTimers[localRealmPath] = setTimeout(() => {
|
|
delete userTimers[localRealmPath];
|
|
refreshAccessToken(user, localRealmPath, realmUrl);
|
|
}, timeout);
|
|
}
|
|
}
|
|
|
|
function print_error() {
|
|
(console.error || console.log).apply(console, arguments);
|
|
}
|
|
|
|
function validateRefresh(user, localRealmPath, response, json) {
|
|
let session = user._sessionForOnDiskPath(localRealmPath);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const errorHandler = session.config.error;
|
|
if (response.status != 200) {
|
|
let error = new AuthError(json);
|
|
if (errorHandler) {
|
|
errorHandler(session, error);
|
|
} else {
|
|
print_error(`Unhandled session token refresh error for user ${user.identity} at path ${localRealmPath}`, error);
|
|
}
|
|
return;
|
|
}
|
|
if (session.state === 'invalid') {
|
|
return;
|
|
}
|
|
return session;
|
|
}
|
|
|
|
function refreshAdminToken(user, localRealmPath, realmUrl) {
|
|
const token = user.token;
|
|
const server = user.server;
|
|
|
|
// We don't need to actually refresh the token, but we need to let ROS know
|
|
// we're accessing the file and get the sync label for multiplexing
|
|
let parsedRealmUrl = url_parse(realmUrl);
|
|
const url = append_url(user.server, 'realms/files/' + encodeURIComponent(parsedRealmUrl.pathname));
|
|
performFetch(url, {method: 'GET', timeout: 10000.0, headers: {Authorization: user.token}})
|
|
.then((response) => {
|
|
// There may not be a Realm Directory Service running on the server
|
|
// we're talking to. If we're talking directly to the sync service
|
|
// we'll get a 404, and if we're running inside ROS we'll get a 503 if
|
|
// the directory service hasn't started yet (perhaps because we got
|
|
// called due to the directory service itself opening some Realms).
|
|
//
|
|
// In both of these cases we can just pretend we got a valid response.
|
|
if (response.status === 404 || response.status === 503) {
|
|
return {response: {status: 200}, json: {path: parsedRealmUrl.pathname, syncLabel: '_direct'}};
|
|
}
|
|
else {
|
|
return response.json().then((json) => { return { response, json }; });
|
|
}
|
|
})
|
|
.then((responseAndJson) => {
|
|
const response = responseAndJson.response;
|
|
const json = responseAndJson.json;
|
|
|
|
const credentials = credentialsMethods.adminToken(token)
|
|
const newUser = user.constructor.login(server, credentials);
|
|
const session = validateRefresh(newUser, localRealmPath, response, json);
|
|
if (session) {
|
|
parsedRealmUrl.set('pathname', json.path);
|
|
session._refreshAccessToken(user.token, parsedRealmUrl.href, json.syncLabel);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
print_error(e);
|
|
setTimeout(() => refreshAccessToken(user, localRealmPath, realmUrl), retryInterval);
|
|
});
|
|
}
|
|
|
|
function refreshAccessToken(user, localRealmPath, realmUrl) {
|
|
if (!user._sessionForOnDiskPath(localRealmPath)) {
|
|
// We're trying to refresh the token for a session that's closed. This could happen, for example,
|
|
// when the server is not reachable and we periodically try to refresh the token, but the user has
|
|
// already closed the Realm file.
|
|
return;
|
|
}
|
|
|
|
if (!user.server) {
|
|
throw new Error("Server for user must be specified");
|
|
}
|
|
|
|
const parsedRealmUrl = url_parse(realmUrl);
|
|
const path = parsedRealmUrl.pathname;
|
|
if (!path) {
|
|
throw new Error(`Unexpected Realm path inferred from url '${realmUrl}'. The path section of the url should be a non-empty string.`);
|
|
}
|
|
|
|
if (user.isAdminToken) {
|
|
return refreshAdminToken(user, localRealmPath, realmUrl);
|
|
}
|
|
|
|
const url = append_url(user.server, 'auth');
|
|
const options = {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
data: user.token,
|
|
path,
|
|
provider: 'realm',
|
|
app_id: ''
|
|
}),
|
|
headers: postHeaders,
|
|
// FIXME: This timeout appears to be necessary in order for some requests to be sent at all.
|
|
// See https://github.com/realm/realm-js-private/issues/338 for details.
|
|
timeout: 10000.0
|
|
};
|
|
performFetch(url, options)
|
|
.then((response) => response.json().then((json) => { return { response, json }; }))
|
|
.then((responseAndJson) => {
|
|
const response = responseAndJson.response;
|
|
const json = responseAndJson.json;
|
|
// Look up a fresh instance of the user.
|
|
// We do this because in React Native Remote Debugging
|
|
// `Realm.clearTestState()` will have invalidated the user object
|
|
let newUser = user.constructor._getExistingUser(user.server, user.identity);
|
|
if (!newUser) {
|
|
return;
|
|
}
|
|
|
|
const session = validateRefresh(newUser, localRealmPath, response, json);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const tokenData = json.access_token.token_data;
|
|
|
|
parsedRealmUrl.set('pathname', tokenData.path);
|
|
session._refreshAccessToken(json.access_token.token, parsedRealmUrl.href, tokenData.sync_label);
|
|
|
|
const errorHandler = session.config.error;
|
|
if (errorHandler && errorHandler._notifyOnAccessTokenRefreshed) {
|
|
errorHandler(session, errorHandler._notifyOnAccessTokenRefreshed)
|
|
}
|
|
|
|
const tokenExpirationDate = new Date(tokenData.expires * 1000);
|
|
scheduleAccessTokenRefresh(newUser, localRealmPath, realmUrl, tokenExpirationDate);
|
|
})
|
|
.catch((e) => {
|
|
print_error(e);
|
|
// in case something lower in the HTTP stack breaks, try again in `retryInterval` seconds
|
|
setTimeout(() => refreshAccessToken(user, localRealmPath, realmUrl), retryInterval);
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The base authentication method. It fires a JSON POST to the server parameter plus the auth url
|
|
* For example, if the server parameter is `http://myapp.com`, this url will post to `http://myapp.com/auth`
|
|
* @param {object} userConstructor
|
|
* @param {string} server the http or https server url
|
|
* @param {object} json the json to post to the auth endpoint
|
|
* @param {Function} callback an optional callback with an error and user parameter
|
|
* @returns {Promise} only returns a promise if the callback parameter was omitted
|
|
*/
|
|
function _authenticate(userConstructor, server, json) {
|
|
json.app_id = '';
|
|
const url = append_url(server, 'auth');
|
|
const options = {
|
|
method: 'POST',
|
|
body: JSON.stringify(json),
|
|
headers: postHeaders,
|
|
open_timeout: 5000
|
|
};
|
|
|
|
return performFetch(url, options)
|
|
.then((response) => {
|
|
const contentType = response.headers.get('Content-Type');
|
|
if (contentType.indexOf('application/json') === -1) {
|
|
return response.text().then((body) => {
|
|
throw new AuthError({
|
|
title: `Could not authenticate: Realm Object Server didn't respond with valid JSON`,
|
|
body,
|
|
});
|
|
});
|
|
} else if (!response.ok) {
|
|
return response.json().then((body) => Promise.reject(new AuthError(body)));
|
|
} else {
|
|
return response.json().then(function (body) {
|
|
// TODO: validate JSON
|
|
const token = body.refresh_token.token;
|
|
const identity = body.refresh_token.token_data.identity;
|
|
const isAdmin = body.refresh_token.token_data.is_admin;
|
|
return userConstructor.createUser(server, identity, token, false, isAdmin);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function _updateAccount(userConstructor, server, json) {
|
|
const url = append_url(server, 'auth/password/updateAccount');
|
|
const options = {
|
|
method: 'POST',
|
|
body: JSON.stringify(json),
|
|
headers: postHeaders,
|
|
open_timeout: 5000
|
|
};
|
|
|
|
return performFetch(url, options)
|
|
.then((response) => {
|
|
const contentType = response.headers.get('Content-Type');
|
|
if (contentType.indexOf('application/json') === -1) {
|
|
return response.text().then((body) => {
|
|
throw new AuthError({
|
|
title: `Could not update user account: Realm Object Server didn't respond with valid JSON`,
|
|
body,
|
|
});
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
return response.json().then((body) => Promise.reject(new AuthError(body)));
|
|
}
|
|
});
|
|
}
|
|
|
|
const credentialsMethods = {
|
|
usernamePassword(username, password, createUser) {
|
|
checkTypes(arguments, ['string', 'string', 'boolean']);
|
|
return new Credentials('password', username, { register: createUser, password });
|
|
},
|
|
|
|
facebook(token) {
|
|
checkTypes(arguments, ['string']);
|
|
return new Credentials('facebook', token);
|
|
},
|
|
|
|
google(token) {
|
|
checkTypes(arguments, ['string']);
|
|
return new Credentials('google', token);
|
|
},
|
|
|
|
anonymous() {
|
|
return new Credentials('anonymous');
|
|
},
|
|
|
|
nickname(value, isAdmin) {
|
|
checkTypes(arguments, ['string', 'boolean']);
|
|
return new Credentials('nickname', value, { is_admin: isAdmin || false });
|
|
},
|
|
|
|
azureAD(token) {
|
|
checkTypes(arguments, ['string']);
|
|
return new Credentials('azuread', token)
|
|
},
|
|
|
|
jwt(token, providerName) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
return new Credentials(providerName || 'jwt', token);
|
|
},
|
|
|
|
adminToken(token) {
|
|
checkTypes(arguments, ['string']);
|
|
return new Credentials('adminToken', token);
|
|
},
|
|
|
|
custom(providerName, token, userInfo) {
|
|
if (userInfo) {
|
|
checkTypes(arguments, ['string', 'string', 'object']);
|
|
} else {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
}
|
|
|
|
return new Credentials(providerName, token, userInfo);
|
|
}
|
|
}
|
|
|
|
const staticMethods = {
|
|
get current() {
|
|
const allUsers = this.all;
|
|
const keys = Object.keys(allUsers);
|
|
if (keys.length === 0) {
|
|
return undefined;
|
|
} else if (keys.length > 1) {
|
|
throw new Error("Multiple users are logged in");
|
|
}
|
|
|
|
return allUsers[keys[0]];
|
|
},
|
|
|
|
login(server, credentials) {
|
|
if (arguments.length === 3) {
|
|
// Deprecated legacy signature.
|
|
checkTypes(arguments, ['string', 'string', 'string']);
|
|
console.warn("User.login is deprecated. Please use User.login(server, Credentials.usernamePassword(...)) instead.");
|
|
const newCredentials = credentialsMethods.usernamePassword(arguments[1], arguments[2], /* createUser */ false);
|
|
return this.login(server, newCredentials);
|
|
}
|
|
|
|
checkTypes(arguments, ['string', 'object']);
|
|
if (credentials.identityProvider === 'adminToken') {
|
|
let u = this._adminUser(server, credentials.token);
|
|
return u;
|
|
}
|
|
|
|
return _authenticate(this, server, credentials);
|
|
},
|
|
|
|
deserialize(serialized) {
|
|
checkObjectTypes(serialized, {
|
|
server: 'string',
|
|
identity: 'string',
|
|
refreshToken: 'string',
|
|
isAdmin: 'boolean',
|
|
});
|
|
|
|
return this.createUser(serialized.server, serialized.identity, serialized.refreshToken, false, serialized.isAdmin || false);
|
|
},
|
|
|
|
requestPasswordReset(server, email) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
const json = {
|
|
provider_id: email,
|
|
data: { action: 'reset_password' }
|
|
};
|
|
|
|
return _updateAccount(this, server, json);
|
|
},
|
|
|
|
completePasswordReset(server, resetToken, newPassword) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
const json = {
|
|
data: {
|
|
action: 'complete_reset',
|
|
token: resetToken,
|
|
new_password: newPassword
|
|
}
|
|
};
|
|
|
|
return _updateAccount(this, server, json);
|
|
},
|
|
|
|
requestEmailConfirmation(server, email) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
const json = {
|
|
provider_id: email,
|
|
data: { action: 'request_email_confirmation' }
|
|
};
|
|
|
|
return _updateAccount(this, server, json);
|
|
},
|
|
|
|
confirmEmail(server, confirmationToken) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
const json = {
|
|
data: {
|
|
action: 'confirm_email',
|
|
token: confirmationToken
|
|
}
|
|
};
|
|
|
|
return _updateAccount(this, server, json);
|
|
},
|
|
|
|
_refreshAccessToken: refreshAccessToken,
|
|
|
|
// Deprecated...
|
|
adminUser(token, server) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
console.warn("User.adminUser is deprecated. Please use User.login(server, Credentials.adminToken(token)) instead.");
|
|
const credentials = credentialsMethods.adminToken(token);
|
|
return this.login(server, credentials);
|
|
},
|
|
|
|
register(server, username, password) {
|
|
checkTypes(arguments, ['string', 'string', 'string']);
|
|
console.warn("User.register is deprecated. Please use User.login(server, Credentials.usernamePassword(...)) instead.");
|
|
const credentials = credentialsMethods.usernamePassword(username, password, /* createUser */ true);
|
|
return this.login(server, credentials);
|
|
},
|
|
|
|
registerWithProvider(server, options) {
|
|
checkTypes(arguments, ['string', 'object']);
|
|
console.warn("User.registerWithProvider is deprecated. Please use User.login(server, Credentials.SOME-PROVIDER(...)) instead.");
|
|
const credentials = credentialsMethods.custom(options.provider, options.providerToken, options.userInfo);
|
|
return this.login(server, credentials);
|
|
},
|
|
|
|
authenticate(server, provider, options) {
|
|
checkTypes(arguments, ['string', 'string', 'object'])
|
|
console.warn("User.authenticate is deprecated. Please use User.login(server, Credentials.SOME-PROVIDER(...)) instead.");
|
|
|
|
let credentials;
|
|
switch (provider.toLowerCase()) {
|
|
case 'jwt':
|
|
credentials = credentialsMethods.jwt(options.token, 'jwt');
|
|
break
|
|
case 'password':
|
|
credentials = credentialsMethods.usernamePassword(options.username, options.password);
|
|
break
|
|
default:
|
|
credentials = credentialsMethods.custom(provider, options.data, options.user_info || options.userInfo);
|
|
break;
|
|
}
|
|
|
|
return this.login(server, credentials);
|
|
},
|
|
};
|
|
|
|
const instanceMethods = {
|
|
logout() {
|
|
this._logout();
|
|
const userTimers = refreshTimers[this.identity];
|
|
if (userTimers) {
|
|
Object.keys(userTimers).forEach((key) => {
|
|
clearTimeout(userTimers[key]);
|
|
});
|
|
|
|
delete refreshTimers[this.identity];
|
|
}
|
|
|
|
const url = url_parse(this.server);
|
|
url.set('pathname', '/auth/revoke');
|
|
const headers = {
|
|
Authorization: this.token
|
|
};
|
|
const body = JSON.stringify({
|
|
token: this.token
|
|
});
|
|
const options = {
|
|
method: 'POST',
|
|
headers,
|
|
body: body,
|
|
open_timeout: 5000
|
|
};
|
|
|
|
return performFetch(url.href, options)
|
|
.catch((e) => print_error('An error occurred while logging out a user', e));
|
|
},
|
|
serialize() {
|
|
return {
|
|
server: this.server,
|
|
refreshToken: this.token,
|
|
identity: this.identity,
|
|
isAdmin: this.isAdmin,
|
|
};
|
|
},
|
|
openManagementRealm() {
|
|
let url = url_parse(this.server);
|
|
if (url.protocol === 'http:') {
|
|
url.set('protocol', 'realm:');
|
|
} else if (url.protocol === 'https:') {
|
|
url.set('protocol', 'realms:');
|
|
} else {
|
|
throw new Error(`Unexpected user auth url: ${this.server}`);
|
|
}
|
|
|
|
url.set('pathname', '/~/__management');
|
|
|
|
return new this.constructor._realmConstructor({
|
|
schema: require('./management-schema'),
|
|
sync: {
|
|
user: this,
|
|
url: url.href
|
|
}
|
|
});
|
|
},
|
|
retrieveAccount(provider, provider_id) {
|
|
checkTypes(arguments, ['string', 'string']);
|
|
const url = url_parse(this.server);
|
|
url.set('pathname', `/auth/users/${provider}/${provider_id}`);
|
|
const headers = {
|
|
Authorization: this.token
|
|
};
|
|
const options = {
|
|
method: 'GET',
|
|
headers,
|
|
open_timeout: 5000
|
|
};
|
|
return performFetch(url.href, options)
|
|
.then((response) => {
|
|
if (response.status !== 200) {
|
|
return response.json()
|
|
.then(body => {
|
|
throw new AuthError(body);
|
|
});
|
|
} else {
|
|
return response.json();
|
|
}
|
|
});
|
|
},
|
|
createConfiguration(config) {
|
|
|
|
if (config && config.sync) {
|
|
if (config.sync.user && console.warn !== undefined) {
|
|
console.warn(`'user' property will be overridden by ${this.identity}`);
|
|
}
|
|
if (config.sync.partial !== undefined && config.sync.fullSynchronization !== undefined) {
|
|
throw new Error("'partial' and 'fullSynchronization' were both set. 'partial' has been deprecated, use only 'fullSynchronization'");
|
|
}
|
|
}
|
|
|
|
// Create default config
|
|
let url = new URL(this.server);
|
|
let secure = (url.protocol === 'https:')?'s':'';
|
|
let port = (url.port === undefined)?'9080':url.port;
|
|
let realmUrl = `realm${secure}://${url.hostname}:${port}/default`;
|
|
|
|
let defaultConfig = {
|
|
sync: {
|
|
user: this,
|
|
url: realmUrl,
|
|
},
|
|
};
|
|
|
|
// Set query-based as the default setting if the user doesn't specified any other behaviour.
|
|
if (!(config && config.sync && config.sync.partial)) {
|
|
defaultConfig.sync.fullSynchronization = false;
|
|
}
|
|
|
|
// Merge default configuration with user provided config. User defined properties should aways win.
|
|
// Doing the naive merge in JS break objects that are backed by native objects, so these needs to
|
|
// be merged manually. This is currently only `sync.user`.
|
|
let mergedConfig = (config === undefined) ? defaultConfig : merge(defaultConfig, config);
|
|
mergedConfig.sync.user = this;
|
|
return mergedConfig;
|
|
},
|
|
};
|
|
|
|
class Credentials {
|
|
constructor(identityProvider, token, userInfo) {
|
|
this.identityProvider = identityProvider;
|
|
this.token = token;
|
|
this.userInfo = userInfo || {};
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
data: this.token,
|
|
provider: this.identityProvider,
|
|
user_info: this.userInfo,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Append the permission apis
|
|
Object.assign(instanceMethods, permissionApis);
|
|
|
|
module.exports = {
|
|
static: staticMethods,
|
|
instance: instanceMethods,
|
|
credentials: credentialsMethods,
|
|
};
|