acebase-server
Version:
AceBase realtime database server (webserver endpoint to allow remote connections)
252 lines • 13.4 kB
JavaScript
import { ID } from 'acebase-core';
import { createPasswordHash, generatePassword } from '../shared/password.js';
import { createPublicAccessToken, createSignedPublicToken, parseSignedPublicToken } from '../shared/tokens.js';
import { fetch } from '../shared/simple-fetch.js';
const socketSignInSuccess = `<html><script>window.close()</script><body>Signed in succesfully. You can <a href="javascript:window.close()">close</a> this page</body></html>`;
const socketSignInFailed = `<html><script>window.close()</script><body>Failed to sign in. You can <a href="javascript:window.close()">close</a> this page</body></html>`;
export const addRoute = (env) => {
env.router.get(`/oauth2/${env.db.name}/signin`, async (req, res) => {
// This is where the user is redirected to by the provider after signin or error
const LOG_ACTION = 'oauth2.signin';
const LOG_DETAILS = { ip: req.ip, provider: null };
try {
const state = parseSignedPublicToken(req.query.state, env.tokenSalt);
if (req.query.error) {
if (state.flow === 'socket') {
const client = env.clients.get(state.client_id);
client.socket.emit('oauth2-signin', { error: req.query.error, reason: req.query.error_reason, description: req.query.error_description, provider: state.provider });
}
else {
const callbackUrl = `${state.callbackUrl}?provider=${state.provider}&error=${req.query.error}&reason=${req.query.error_reason}&description=${req.query.error_description}`;
res.redirect(callbackUrl);
}
return;
}
// Got authorization code
const authCode = req.query.code;
const provider = env.authProviders[state.provider];
LOG_DETAILS.provider = state.provider;
// Get access & refresh tokens
const tokens = await provider.getAccessToken({ type: 'auth', auth_code: authCode, redirect_url: `${req.protocol}://${req.headers.host}/oauth2/${env.db.name}/signin` });
// let user_details;
// TODO: Have we got an id_token?
// if (tokens.id_token) {
// // decode, extract user information
// }
// else {
const user_details = await provider.getUserInfo(tokens.access_token);
// }
if (user_details.picture && user_details.picture.length > 0) {
// Download it, convert to base64
const best = user_details.picture.sort((a, b) => a.width * a.height > b.width * b.height ? -1 : 1)[0];
try {
const response = await fetch(best.url);
const contentType = response.headers.get('Content-Type');
if (contentType === 'image/png') { //state.provider === 'google' &&
// Don't accept image/png, because it's probably a placeholder image. Google does this by creating a png with people's initials
user_details.picture = [];
}
else {
const image = await response.arrayBuffer();
const buff = Buffer.from(image);
best.url = `data:${contentType};base64,${buff.toString('base64')}`;
user_details.picture = [best]; // Only keep the best one
}
}
catch (err) {
env.debug.warn(`Could not fetch profile picture from "${best.url}": `, err);
user_details.picture = null;
}
}
const getProviderSettings = () => {
// Returns an object with all info (except picture) the provider has about the user
const settings = {
[`${state.provider}_id`]: user_details.id,
[`${state.provider}_email`]: user_details.email,
[`${state.provider}_email_verified`]: user_details.email_verified,
[`${state.provider}_name`]: user_details.name,
[`${state.provider}_display_name`]: user_details.display_name,
};
Object.keys(user_details.other).forEach(key => {
settings[`${state.provider}_${key}`] = user_details.other[key];
});
return settings;
};
const providerUsername = `${state.provider}:${user_details.id}`;
// Check if this user exists in the database
let snaps;
let addToExistingAccount = true;
if (typeof state.uid === 'string') {
// Use the signed in uid to link this account to. This allows multiple auth provider
// accounts (with different email addresses) to be linked to the account the user
// is signed into, and also allows multiple AceBase users to link to the same provider
// accounts, eg if a client app allows users to link their own account to a shared
// family Spotify / Dropbox account.
const snap = await env.authRef.child(state.uid).get();
if (!snap.exists()) {
// This is wrong!
throw new Error(`Invalid uid`);
}
snaps = [snap];
const user = snap.val();
addToExistingAccount = user.email === user_details.email;
}
else {
const query = env.authRef.query();
if (user_details.email) {
query.filter('email', '==', user_details.email);
}
else {
// User did not allow reading e-mail address, or provider does not have one (eg whatsapp?)
// Switch to using a generated username such as "facebook-3292389234" instead
query.filter('username', '==', providerUsername);
}
snaps = await query.get();
}
if (snaps.length === 0 && user_details.email) {
// Try again with providerUsername, use might previously have denied access to email,
// and now has granted access. In that case, we'll already have an account with the
// generated providerUsername
snaps = await env.authRef.query().filter('username', '==', providerUsername).get();
}
let user;
if (snaps.length === 1) {
const uid = snaps[0].key;
user = snaps[0].val();
user.uid = uid;
if (addToExistingAccount) {
// Update user details
user.email_verified = user.email_verified || user_details.email_verified;
user.email = user.email || user_details.email;
if (user_details.picture?.length > 0) {
user.picture = user_details.picture[0];
}
await env.authRef.child(uid).update({
email: user.email || null,
email_verified: user.email_verified,
last_signin: new Date(),
last_signin_ip: req.ip,
picture: user.picture,
});
}
// Add provider details
await env.authRef.child(uid).child('settings').update(getProviderSettings());
// Log success
env.log.event(LOG_ACTION, { ...LOG_DETAILS, uid });
// Cache the user
env.authCache.set(user.uid, user);
// Request signin e-mail to be sent
const request = {
type: 'user_signin',
user: {
uid: user.uid,
username: user.username,
email: user.email,
displayName: user.display_name,
settings: user.settings,
},
date: user.created,
ip: req.ip,
activationCode: user.email_verified ? null : createSignedPublicToken({ uid: user.uid }, env.tokenSalt),
emailVerified: user.email_verified,
provider: state.provider,
};
env.config.email?.send(request).catch(err => {
env.log.error(LOG_ACTION + '.email', 'unexpected', { ...LOG_DETAILS, uid, request }, err);
});
}
else if (snaps.length === 0) {
// User does not exist, create
if (!env.config.auth.allowUserSignup) {
env.log.error(LOG_ACTION, 'user_signup_disabled', { ...LOG_DETAILS, email: user_details.email });
res.statusCode = 403; // Forbidden
return res.send({ code: 'admin_only', message: 'Only admin is allowed to create users' });
}
// Create user with Generated password
const pwd = createPasswordHash(generatePassword());
user = {
uid: null,
username: typeof user_details.email === 'undefined' ? providerUsername : null,
email: user_details.email || null,
email_verified: user_details.email_verified,
display_name: user_details.display_name,
password: pwd.hash,
password_salt: pwd.salt,
created: new Date(),
created_ip: req.ip,
access_token: ID.generate(),
access_token_created: new Date(),
last_signin: new Date(),
last_signin_ip: req.ip,
picture: user_details.picture && user_details.picture[0],
settings: getProviderSettings(),
};
const userRef = await env.authRef.push(user);
const uid = userRef.key;
user.uid = uid;
// Log success
env.log.event(LOG_ACTION, { ...LOG_DETAILS, uid });
// Cache the user
env.authCache.set(user.uid, user);
// Request welcome e-mail to be sent
const request = {
type: 'user_signup',
user: {
uid: user.uid,
username: user.username,
email: user.email,
displayName: user.display_name,
settings: user.settings,
},
date: user.created,
ip: user.created_ip,
activationCode: user.email_verified ? null : createSignedPublicToken({ uid: user.uid }, env.tokenSalt),
emailVerified: user.email_verified,
provider: state.provider,
};
env.config.email?.send(request).catch(err => {
env.log.error(LOG_ACTION + '.email', 'unexpected', { ...LOG_DETAILS, uid, request }, err);
});
}
else {
// More than 1?!!
if (state.flow === 'socket') {
const client = env.clients.get(state.client_id);
client.socket.emit('oauth2-signin', { action: 'error', error: 'account_duplicates' });
return res.send(socketSignInFailed);
}
else {
const callbackUrl = `${state.callbackUrl}?provider=${state.provider}&error=account_duplicates`;
return res.redirect(callbackUrl);
}
}
const result = {
provider: {
name: state.provider,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
},
access_token: createPublicAccessToken(user.uid, req.ip, user.access_token, env.tokenSalt),
// Disabled sending user details because:
// 1) they might be too big to send as redirect header,
// 2) client should verify the sent access token and get user details from the server
// user: getPublicAccountDetails(user)
};
if (state.flow === 'socket') {
const client = env.clients.get(state.client_id);
client.socket.emit('oauth2-signin', { action: 'success', result });
res.send(socketSignInSuccess);
}
else {
const base64Result = Buffer.from(JSON.stringify(result)).toString('base64');
const callbackUrl = `${state.callbackUrl}?result=${base64Result}`;
res.redirect(callbackUrl);
}
}
catch (err) {
res.status(500).send(err.message);
}
});
};
//# sourceMappingURL=oauth2-signin.js.map