はじめに
この記事は初めてのAuth0ハンズオンの続編で、Auth0のユニークな機能の一つであるLinking User Accountsの設定手順です。Linking User Accountsは、複数のIdPに存在するユーザアカウントを任意のキーでリンクする機能です。
事前準備
事前に下記をご準備お願いします。
- MacまたはWindows PC
- Chrome
- Node.js, Node Package Manager
- Auth0の無料トライアルアカウント
手順
設定
左ペインの"Extensions"をクリック、検索フィールドに"account"と入力して"Auth0 Account Link"をクリック、"INSTALL"ボタンを押します。
左ペインの"Rules"をクリックして、"auth0-account-link-extension"が作成されていることを確認します。
Auth0 Account Link Extensionをインストールすると自動的にこのルールが作成されます。
Edit RuleをクリックしてScriptを確認します。
function (user, context, callback) {
/**
* This rule has been automatically generated by
* Unknown at 2019-12-03T22:21:42.509Z
*/
var request = require('request@2.56.0');
var queryString = require('querystring');
var Promise = require('native-or-bluebird@1.2.0');
var jwt = require('jsonwebtoken@7.1.9');
var CONTINUE_PROTOCOL = 'redirect-callback';
var LOG_TAG = '[ACCOUNT_LINK]: ';
console.log(LOG_TAG, 'Entered Account Link Rule');
// 'query' can be undefined when using '/oauth/token' to log in
context.request.query = context.request.query || {};
var config = {
endpoints: {
linking: 'https://kiriko.us8.webtask.io/4cb95bf92ced903b9b84ebedbf5ebffd',
userApi: auth0.baseUrl + '/users',
usersByEmailApi: auth0.baseUrl + '/users-by-email'
},
token: {
clientId: 'ggNy04f38ewFIdzGQgyF0dUyNhyrlKHm',
clientSecret: 'QNGSYY8d-8b_5kRGvZqPedlq3wzPYjws0fO86Q24lrhtjMf-OM0OleDId4NlCeDX',
issuer: auth0.domain
}
};
// If the user does not have an e-mail account,
// just continue the authentication flow.
// See auth0-extensions/auth0-account-link-extension#33
if (user.email === undefined) {
return callback(null, user, context);
}
createStrategy().then(callbackWithSuccess).catch(callbackWithFailure);
function createStrategy() {
if (shouldLink()) {
return linkAccounts();
} else if (shouldPrompt()) {
return promptUser();
}
return continueAuth();
function shouldLink() {
return !!context.request.query.link_account_token;
}
function shouldPrompt() {
return !insideRedirect() && !redirectingToContinue() && firstLogin();
// Check if we're inside a redirect
// in order to avoid a redirect loop
// TODO: May no longer be necessary
function insideRedirect() {
return context.request.query.redirect_uri &&
context.request.query.redirect_uri.indexOf(config.endpoints.linking) !== -1;
}
// Check if this is the first login of the user
// since merging already active accounts can be a
// destructive action
function firstLogin() {
return context.stats.loginsCount <= 1;
}
// Check if we're coming back from a redirect
// in order to avoid a redirect loop. User will
// be sent to /continue at this point. We need
// to assign them to their primary user if so.
function redirectingToContinue() {
return context.protocol === CONTINUE_PROTOCOL;
}
}
}
function verifyToken(token, secret) {
return new Promise(function(resolve, reject) {
jwt.verify(token, secret, function(err, decoded) {
if (err) {
return reject(err);
}
return resolve(decoded);
});
});
}
function linkAccounts() {
var secondAccountToken = context.request.query.link_account_token;
return verifyToken(secondAccountToken, config.token.clientSecret)
.then(function(decodedToken) {
// Redirect early if tokens are mismatched
if (user.email !== decodedToken.email) {
console.error(LOG_TAG, 'User: ', decodedToken.email, 'tried to link to account ', user.email);
context.redirect = {
url: buildRedirectUrl(secondAccountToken, context.request.query, 'accountMismatch')
};
return user;
}
var linkUri = config.endpoints.userApi+'/'+user.user_id+'/identities';
var headers = {
Authorization: 'Bearer ' + auth0.accessToken,
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
};
return apiCall({
method: 'GET',
url: config.endpoints.userApi+'/'+decodedToken.sub+'?fields=identities',
headers: headers
})
.then(function(secondaryUser) {
var provider = secondaryUser &&
secondaryUser.identities &&
secondaryUser.identities[0] &&
secondaryUser.identities[0].provider;
return apiCall({
method: 'POST',
url: linkUri,
headers,
json: { user_id: decodedToken.sub, provider: provider }
});
})
.then(function(_) {
// TODO: Ask about this
console.info(LOG_TAG, 'Successfully linked accounts for user: ', 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 !== 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) {
context.redirect = {
url: buildRedirectUrl(createToken(config.token), context.request.query)
};
}
});
}
function callbackWithSuccess(_) {
callback(null, user, context);
return _;
}
function callbackWithFailure(err) {
console.error(LOG_TAG, err.message, err.stack);
callback(err, user, context);
}
function createToken(tokenInfo, targetUsers) {
var options = {
expiresIn: '5m',
audience: tokenInfo.clientId,
issuer: qualifyDomain(tokenInfo.issuer)
};
var userSub = {
sub: user.user_id,
email: user.email,
base: auth0.baseUrl
};
return jwt.sign(userSub, tokenInfo.clientSecret, options);
}
function searchUsersWithSameEmail() {
return apiCall({
url: config.endpoints.usersByEmailApi,
qs: {
email: 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,
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 ' + auth0.accessToken,
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);
}
});
});
}
}
動作確認
この記事では、Email:cookiewanwan@gmail.comのアカウントをAuth0のDatabaseに作成し、その後GoogleのアカウントとしてEmail:cookiewanwan@gmail.comでログインするタイミングでアカウントをリンクしています。
左ペインの"Connections"->"Social"をクリックして”Google”を有効化します。初めてのAuth0ハンズオンので作成したApplicationを起動してApplicationを起動、Chromeでhttp://localhost:3000
にアクセスしてユーザをSign-upします。
ログアウト後、再度Loginをクリック、Email:cookiewanwan@gmail.comのGoogleアカウントでログインします。
ログインに成功するとRuleが発動しアカウントリンクの許諾が表示されます。
”CONTINUE”を押します。
初回にSign-upしたcookiewanwan@gmail.comで再度ログインします。
ログインに成功すると再度Ruleが発動しアカウントがリンクされます。
左ペインの"Users&Roles"->"Users"から該当のユーザをクリック、"ACCOUNTS/ASSOCIATED"に"Google/Gmail"と表示されていれば成功です。
おわりに
Auth0のような認可サーバが複数のIdPと接続する環境では、同一のIDを持ったアカウントが複数作成されてしてしまう懸念があるかと思います。Auth0は同一のIDを持ったアカウントを任意のキーでリンクすることで一貫性のあるユーザ体験を提供することが可能です。