Open ADTC opened 1 year ago
Tested conversion of auth0-account-link-extension from Rule to Action below. Remember to
const request = require("request");
const queryString = require("querystring");
const Promise = require("native-or-bluebird");
const jwt = require("jsonwebtoken");
const axios = require("axios");
exports.onExecutePostLogin = async (event, api) => {
var LOG_TAG = '[ACTION_ACCOUNT_LINK] ';
var CONTINUE_PROTOCOL = 'redirect-callback';
var Auth0ManagementAccessToken = '';
event.request.query = event.request.query || {};
var config = {
endpoints: {
linking: `https://${event.secrets.AUTH0_DOMAIN}.webtask.run/4cb95bf92ced903b9b84ebedbf5ebffd`,
userApi: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/api/v2/users`,
usersByEmailApi: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/api/v2/users-by-email`
},
token: {
clientId: event.secrets.M2M_CLIENT_ID,
clientSecret: event.secrets.M2M_CLIENT_SECRET,
issuer: `${event.secrets.AUTH0_DOMAIN}.auth0.com`
}
};
if (event.user.email === undefined) {
console.log(LOG_TAG, 'Account Link Action: No event.user.email');
return;
}
await createStrategy().then(callbackWithSuccess).catch(callbackWithFailure);
async function createStrategy()
{
if (shouldLink())
{
await setManagementAccessToken(true);
return linkAccounts();
}
if (shouldPrompt())
{
await setManagementAccessToken(false);
return promptUser();
}
return continueAuth();
function shouldLink() {
return !!event.request.query.link_account_token;
}
function shouldPrompt() {
return !insideRedirect() && !redirectingToContinue() && firstLogin();
function insideRedirect() {
return event.request.query.redirect_uri &&
event.request.query.redirect_uri.indexOf(config.endpoints.linking) !== -1;
}
function firstLogin() {
return event.stats.logins_count <= 1;
}
function redirectingToContinue() {
return event.protocol === CONTINUE_PROTOCOL;
}
}
}
async function setManagementAccessToken(shouldLink)
{
if (Auth0ManagementAccessToken !== ''){
return;
}
if (shouldLink) {
var options = {
method: 'POST',
url: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/oauth/token`,
headers: {'content-type': 'application/x-www-form-urlencoded'},
data: new URLSearchParams({
grant_type: 'client_credentials',
client_id: event.secrets.APP_CLIENT_ID,
client_secret: event.secrets.APP_CLIENT_SECRET,
audience: event.secrets.BASE_URL + "/"
})
};
}
else {
var options = {
method: 'POST',
url: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/oauth/token`,
headers: {'content-type': 'application/x-www-form-urlencoded'},
data: new URLSearchParams({
grant_type: 'client_credentials',
client_id: event.secrets.M2M_CLIENT_ID,
client_secret: event.secrets.M2M_CLIENT_SECRET,
audience: event.secrets.BASE_URL + "/"
})
};
}
try {
const response = await axios.request(options);
Auth0ManagementAccessToken = response.data.access_token;
} catch (error) {
console.error(LOG_TAG, "axios error:" + error + " for options: " + JSON.stringify(options, null, 2));
return;
}
}
function verifyToken(token, secret) {
return new Promise(function(resolve, reject) {
jwt.verify(token, secret, function(err, decoded) {
if (err) {
console.error(LOG_TAG, `verifyToken error: ${err}`);
return reject(err);
}
return resolve(decoded);
});
});
}
function linkAccounts() {
var secondAccountToken = event.request.query.link_account_token;
return verifyToken(secondAccountToken, config.token.clientSecret)
.then(function(decodedToken) {
// Redirect early if tokens are mismatched
if (event.user.email !== decodedToken.email) {
console.error(LOG_TAG, 'User: ', decodedToken.email, 'tried to link to account ', event.user.email);
event.redirect = {
url: buildRedirectUrl(secondAccountToken, event.request.query, 'accountMismatch')
};
return event.user;
}
var headers = {
Authorization: 'Bearer ' + Auth0ManagementAccessToken,
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
};
var getUrl = config.endpoints.userApi+'/'+decodedToken.sub+'?fields=identities';
return apiCall({
method: 'GET',
url: getUrl,
headers: headers
})
.then(function(secondaryUser) {
var provider = secondaryUser &&
secondaryUser.identities &&
secondaryUser.identities[0] &&
secondaryUser.identities[0].provider;
var linkUri = config.endpoints.userApi + '/' + event.user.user_id + '/identities';
return apiCall({
method: 'POST',
url: linkUri,
headers,
json: { user_id: decodedToken.sub, provider: provider }
});
})
.then(function(_) {
console.info(LOG_TAG, 'Successfully linked accounts for user: ', event.user.email);
return _;
});
});
}
function continueAuth() {
return Promise.resolve();
}
function promptUser() {
return searchUsersWithSameEmail().then(function transformUsers(users) {
return users.filter(function(u) {
return u.user_id !== event.user.user_id;
}).map(function(user) {
return {
userId: user.user_id,
email: user.email,
picture: user.picture,
connections: user.identities.map(function(identity) {
return identity.connection;
})
};
});
}).then(function redirectToExtension(targetUsers) {
if (targetUsers.length > 0) {
event.redirect = {
url: buildRedirectUrl(createToken(config.token), event.request.query)
};
}
});
}
function callbackWithSuccess(_) {
if (api.redirect.canRedirect() && event.redirect) {
api.redirect.sendUserTo(event.redirect.url);
}
return;
}
function callbackWithFailure(err) {
console.error(LOG_TAG, err.message, err.stack);
api.access.deny(err.message);
}
function createToken(tokenInfo, targetUsers) {
var options = {
expiresIn: '5m',
audience: tokenInfo.clientId,
issuer: qualifyDomain(tokenInfo.issuer)
};
var userSub = {
sub: event.user.user_id,
email: event.user.email,
base: event.secrets.BASE_URL
};
return jwt.sign(userSub, tokenInfo.clientSecret, options);
}
function searchUsersWithSameEmail() {
return apiCall({
url: config.endpoints.usersByEmailApi,
qs: {
email: event.user.email
}
});
}
// Consider moving this logic out of the rule and into the extension
function buildRedirectUrl(token, q, errorType) {
var params = {
child_token: token,
audience: q.audience,
client_id: q.client_id,
redirect_uri: q.redirect_uri,
scope: q.scope,
response_type: q.response_type,
response_mode: q.response_mode,
auth0Client: q.auth0Client,
original_state: q.original_state || q.state,
nonce: q.nonce,
error_type: errorType
};
return config.endpoints.linking + '?' + queryString.encode(params);
}
function qualifyDomain(domain) {
return 'https://'+domain+'/';
}
function apiCall(options) {
return new Promise(function(resolve, reject) {
var reqOptions = Object.assign({
url: options.url,
headers: {
Authorization: 'Bearer ' + Auth0ManagementAccessToken,
Accept: 'application/json'
},
json: true
}, options);
request(reqOptions, function handleResponse(err, response, body) {
if (err) {
reject(err);
} else if (response.statusCode < 200 || response.statusCode >= 300) {
console.error(LOG_TAG, 'API call failed: ', body);
reject(new Error(body));
} else {
resolve(response.body);
}
});
});
}
};
Secrets explanations:
Thanks for this @EgilSandfeld, ~but if I understand you correctly, we just have to copy the code of our existing Rule, and create a new Action with it, with the dependencies set correctly and some minor code changes done.~
~There are only minor changes from the code of the Rule to the new code of the Action, while most of the code remains intact. I suggest to revise your answer to remove the unchanged code, and only instruct the changes to be done.~ Mistaken, please ignore.
Maybe I am mistaken. I did a comparison and it seems a lot of global variables are changed in Actions, so we'll need to do plenty of search and replace. I think the code above is the result of that.
Why isn't Promise = require('native-or-bluebird')
but now require("promise")
?
Why isn't
Promise = require('native-or-bluebird')
but nowrequire("promise")
?
You're right, missed that the Rule already had versions and package names. Fixed in the code example
I think most of the changes in variable names could be avoided if we just use the following instead of exports.onExecutePostLogin = async (event, api)
:
exports.onExecutePostLogin = async (context, api) => {
const user = context.user;
const auth0 = {
baseUrl: context.tenant.baseUrl, // or is it api.baseUrl ?
domain: context.tenant.id,
accessToken: api.accessToken,
};
// ...
}
This will help most of the code remain intact from the original Rule code, as we're ensuring the existing variable names are reused.
Regardless, are your replacements of callback(...)
with return;
or api.access.deny(err.message);
tested and verified correct? That will be my main concern.
Again, not tested.
And yes great idea to restore the variable names.
Bedste hilsner / Best regards Egil Sandfeld On Oct 9, 2023, 16:46 +0200, ADTC @.***>, wrote:
I think most of the changes in variable names could be avoided if we just use the following instead of exports.onExecutePostLogin = async (event, api): exports.onExecutePostLogin = async (context, api) => { const user = context.user; const auth0 = { baseUrl: context.tenant.baseUrl, // or is it api.baseUrl ? domain: context.tenant.id, accessToken: api.accessToken, };
// ...
} This will help most of the code remain intact from the original Rule code, as we're ensuring the existing variable names are reused. Regardless, are your replacements of callback(...) with return; or api.access.deny(err.message); tested and verified correct? That will be my main concern. — Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you were mentioned.Message ID: @.***>
Okay. Thank you anyway, this is a great start. I'll take some time soon to look through the official guides for rule-to-action conversion and do a conversion of my own. Perhaps posting steps here.
Wish I could create a pull request, but that would require deeper dive into the API on how to create an Action programmatically. And if that's not possible or only partially possible, then instructions would have to be added in the Readme or the installation interface.
@ADTC I've updated and tested my code today. Works now with Actions, so I was able to successfully link a Twitch account into an email account, and I could therefore disable the existing Rule doing the same things.
Obviously the code can be improved, but it works for now 🙌
Thank you for testing it! BTW, may I suggest listing the dependencies here in text form, rather than out-linking to Imgur? It can be second level bullet points.
* First level
* Second level
Looks like:
Thank you!
PS: Also, it's possible to paste images here in GitHub comments if you really want to.
@EgilSandfeld Thank you for your comment with the adjusted action.
Could you please adjust axious@1.5.1 to axios@1.5.1 in your comment. That should prevent others from searching for a solution why the action build in auth0 fails when simply copy pasting your dependencies from the comment.
🤣 It's funny how the double asterisks makes the list look weird, with both first level and second level bullets on the same line:
* First level
* * Second level
(compared to:)
* First level
* Second level
Dear GitHub, WTH? 😂
@EgilSandfeld thank you for the action!
Auth0 appear to hide rules for new tenants, so when enabling the extension you get the broken rule added and then are stuck as you can't disable it.
I found you could still list the rules with the management API and then disable the rule with the update API. https://auth0.com/docs/api/management/v2/rules/get-rules https://auth0.com/docs/api/management/v2/rules/patch-rules-by-id
Hopefully auth0 will come up with something official for actions.
Honestly I wish Auth0 made the feature native and not have to rely on extensions (and this one looks abandoned). I cannot use the New Universal Login Experience if I also want this feature simultaneously. It's terrible.
Does it make sense to rework only the Rule leaving out the rest? As I understand it, the extension is based on the webtask that ties together the initial login, login to the primary account and error handling. Couldn't it be dropped in the future as well once the extension is completely abandoned?
Also an Auth0 employee says the following in AMA:
Account linking is now available for Actions and we recommend that you move to Actions-based account linking from extension, as it will follow its own product development cycle that we don’t plan to continue support with.
@ADTC Where does this come from?
I cannot use the New Universal Login Experience if I also want this feature simultaneously.
Does it make sense to rework only the Rule leaving out the rest? As I understand it, the extension is based on the webtask that ties together the initial login, login to the primary account and error handling. Couldn't it be dropped in the future as well once the extension is completely abandoned?
Also an Auth0 employee says the following in AMA:
Account linking is now available for Actions and we recommend that you move to Actions-based account linking from extension, as it will follow its own product development cycle that we don’t plan to continue support with.
They stated that Account linking is available for actions but what they also tell you that you need to take care of showing user logins, authenticate them etc. Is not like they did a replacement solution with FE included as this extension.
@glebignatieff if you need to insert custom logic you're forced to switch to the classic login. The "New Universal Login Experience" didn't allow custom code when I last checked.
Hello @EgilSandfeld, Thank you for providing this example code, really useful!
Could you just help me with something: in our case we are using a custom domain, and I can't seem to be able to make your code work; do you reckon that anything should change in this scenario? Thank you for the help!
Since Rules are getting deprecated, there should be a migration from using a rule to using an action.
Hopefully a guide can be provided to migrate existing installations, and perhaps this can be done already for new installs so that we don't have to manually do it.
PS: Hopefully this also resolves #163