Skip to content

Commit ea523b3

Browse files
committed
feat: main meat for supporting multiple openid clients
1 parent c016866 commit ea523b3

2 files changed

Lines changed: 95 additions & 213 deletions

File tree

library.js

Lines changed: 94 additions & 212 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,16 @@
11
'use strict';
22

3-
/*
4-
Welcome to the SSO OAuth plugin! If you're inspecting this code, you're probably looking to
5-
hook up NodeBB with your existing OAuth endpoint.
6-
7-
Step 1: Fill in the "constants" section below with the requisite informaton. Either the "oauth"
8-
or "oauth2" section needs to be filled, depending on what you set "type" to.
9-
10-
Step 2: Give it a whirl. If you see the congrats message, you're doing well so far!
11-
12-
Step 3: Customise the `parseUserReturn` method to normalise your user route's data return into
13-
a format accepted by NodeBB. Instructions are provided there. (Line 146)
14-
15-
Step 4: If all goes well, you'll be able to login/register via your OAuth endpoint credentials.
16-
*/
17-
183
const User = require.main.require('./src/user');
194
const Groups = require.main.require('./src/groups');
205
const db = require.main.require('./src/database');
216
const authenticationController = require.main.require('./src/controllers/authentication');
227
const routeHelpers = require.main.require('./src/routes/helpers');
238

24-
const async = require('async');
25-
269
const passport = module.parent.require('passport');
2710
const nconf = module.parent.require('nconf');
2811
const winston = module.parent.require('winston');
2912

30-
/**
31-
* REMEMBER
32-
* Never save your OAuth Key/Secret or OAuth2 ID/Secret pair in code! It could be published and leaked accidentally.
33-
* Save it into your config.json file instead:
34-
*
35-
* {
36-
* ...
37-
* "oauth": {
38-
* "id": "someoauthid",
39-
* "secret": "youroauthsecret"
40-
* }
41-
* ...
42-
* }
43-
*
44-
* ... or use environment variables instead:
45-
*
46-
* `OAUTH__ID=someoauthid OAUTH__SECRET=youroauthsecret node app.js`
47-
*/
48-
49-
const constants = Object.freeze({
50-
type: '', // Either 'oauth' or 'oauth2'
51-
name: '', // Something unique to your OAuth provider in lowercase, like "github", or "nodebb"
52-
oauth: {
53-
requestTokenURL: '',
54-
accessTokenURL: '',
55-
userAuthorizationURL: '',
56-
consumerKey: nconf.get('oauth:key'), // don't change this line
57-
consumerSecret: nconf.get('oauth:secret'), // don't change this line
58-
},
59-
oauth2: {
60-
authorizationURL: '',
61-
tokenURL: '',
62-
clientID: nconf.get('oauth:id'), // don't change this line
63-
clientSecret: nconf.get('oauth:secret'), // don't change this line
64-
},
65-
userRoute: '', // This is the address to your app's "user profile" API endpoint (expects JSON)
66-
});
67-
6813
const OAuth = module.exports;
69-
let configOk = false;
70-
let passportOAuth;
71-
let opts;
72-
73-
if (!constants.name) {
74-
winston.error('[sso-oauth] Please specify a name for your OAuth provider (library.js:32)');
75-
} else if (!constants.type || (constants.type !== 'oauth' && constants.type !== 'oauth2')) {
76-
winston.error('[sso-oauth] Please specify an OAuth strategy to utilise (library.js:31)');
77-
} else if (!constants.userRoute) {
78-
winston.error('[sso-oauth] User Route required (library.js:31)');
79-
} else {
80-
configOk = true;
81-
}
8214

8315
OAuth.init = async (params) => {
8416
const { router /* , middleware , controllers */ } = params;
@@ -111,9 +43,9 @@ OAuth.addAdminNavigation = (header) => {
11143
return header;
11244
};
11345

114-
OAuth.listStrategies = async () => {
46+
OAuth.listStrategies = async (full) => {
11547
const names = await db.getSortedSetMembers('oauth2-multiple:strategies');
116-
const strategies = await db.getObjects(names.map(name => `oauth2-multiple:strategies:${name}`), ['enabled']);
48+
const strategies = await db.getObjects(names.map(name => `oauth2-multiple:strategies:${name}`), full ? undefined : ['enabled']);
11749
strategies.forEach((strategy, idx) => {
11850
strategy.name = names[idx];
11951
strategy.enabled = strategy.enabled === 'true';
@@ -123,184 +55,134 @@ OAuth.listStrategies = async () => {
12355
return strategies;
12456
};
12557

126-
OAuth.getStrategy = function (strategies, callback) {
127-
if (configOk) {
128-
passportOAuth = require('passport-oauth')[constants.type === 'oauth' ? 'OAuthStrategy' : 'OAuth2Strategy'];
129-
130-
if (constants.type === 'oauth') {
131-
// OAuth options
132-
opts = constants.oauth;
133-
opts.callbackURL = `${nconf.get('url')}/auth/${constants.name}/callback`;
134-
135-
passportOAuth.Strategy.prototype.userProfile = function (token, secret, params, done) {
136-
// If your OAuth provider requires the access token to be sent in the query parameters
137-
// instead of the request headers, comment out the next line:
138-
this._oauth._useAuthorizationHeaderForGET = true;
139-
140-
this._oauth.get(constants.userRoute, token, secret, (err, body/* , res */) => {
141-
if (err) {
142-
return done(err);
143-
}
144-
145-
try {
146-
const json = JSON.parse(body);
147-
OAuth.parseUserReturn(json, (err, profile) => {
148-
if (err) return done(err);
149-
profile.provider = constants.name;
150-
151-
done(null, profile);
152-
});
153-
} catch (e) {
154-
done(e);
155-
}
156-
});
157-
};
158-
} else if (constants.type === 'oauth2') {
159-
// OAuth 2 options
160-
opts = constants.oauth2;
161-
opts.callbackURL = `${nconf.get('url')}/auth/${constants.name}/callback`;
162-
163-
passportOAuth.Strategy.prototype.userProfile = function (accessToken, done) {
164-
// If your OAuth provider requires the access token to be sent in the query parameters
165-
// instead of the request headers, comment out the next line:
166-
this._oauth2._useAuthorizationHeaderForGET = true;
167-
168-
this._oauth2.get(constants.userRoute, accessToken, (err, body/* , res */) => {
169-
if (err) {
170-
return done(err);
171-
}
172-
173-
try {
174-
const json = JSON.parse(body);
175-
OAuth.parseUserReturn(json, (err, profile) => {
176-
if (err) return done(err);
177-
profile.provider = constants.name;
178-
179-
done(null, profile);
180-
});
181-
} catch (e) {
182-
done(e);
183-
}
184-
});
185-
};
186-
}
187-
188-
opts.passReqToCallback = true;
189-
190-
passport.use(constants.name, new passportOAuth(opts, async (req, token, secret, profile, done) => {
191-
const user = await OAuth.login({
192-
oAuthid: profile.id,
193-
handle: profile.displayName,
194-
email: profile.emails[0].value,
195-
isAdmin: profile.isAdmin,
196-
});
197-
198-
authenticationController.onSuccessfulLogin(req, user.uid);
199-
done(null, user);
200-
}));
201-
202-
strategies.push({
203-
name: constants.name,
204-
url: `/auth/${constants.name}`,
205-
callbackURL: `/auth/${constants.name}/callback`,
206-
icon: 'fa-check-square',
207-
scope: (constants.scope || '').split(','),
58+
OAuth.loadStrategies = async (strategies) => {
59+
const passportOAuth = require('passport-oauth').OAuth2Strategy;
60+
61+
let configured = await OAuth.listStrategies(true);
62+
configured = configured.filter(obj => obj.enabled);
63+
64+
const configs = configured.map(({
65+
name,
66+
authUrl: authorizationURL,
67+
tokenUrl: tokenURL,
68+
id: clientID,
69+
secret: clientSecret,
70+
callbackUrl: callbackURL,
71+
}) => new passportOAuth({
72+
authorizationURL,
73+
tokenURL,
74+
clientID,
75+
clientSecret,
76+
callbackURL,
77+
passReqToCallback: true,
78+
}, async (req, token, secret, { id, displayName, email }, done) => {
79+
const user = await OAuth.login({
80+
name,
81+
oAuthid: id,
82+
handle: displayName,
83+
email,
20884
});
20985

210-
callback(null, strategies);
211-
} else {
212-
callback(new Error('OAuth Configuration is invalid'));
213-
}
214-
};
86+
authenticationController.onSuccessfulLogin(req, user.uid);
87+
done(null, user);
88+
}));
89+
90+
configs.forEach((strategy, idx) => {
91+
strategy.userProfile = OAuth.getUserProfile.bind(strategy, configured[idx].name, configured[idx].userRoute);
92+
passport.use(configured[idx].name, strategy);
93+
});
21594

216-
OAuth.parseUserReturn = function (data, callback) {
217-
// Alter this section to include whatever data is necessary
218-
// NodeBB *requires* the following: id, displayName, emails.
219-
// Everything else is optional.
95+
strategies.push(...configured.map(({ name }) => ({
96+
name,
97+
url: `/auth/${name}`,
98+
callbackURL: `/auth/${name}/callback`,
99+
icon: 'fa-check-square',
100+
scope: 'openid email profile',
101+
})));
220102

221-
// Find out what is available by uncommenting this line:
222-
// console.log(data);
103+
return strategies;
104+
};
223105

224-
const profile = {};
225-
profile.id = data.id;
226-
profile.displayName = data.name;
227-
profile.emails = [{ value: data.email }];
106+
OAuth.getUserProfile = function (name, userRoute, accessToken, done) {
107+
// If your OAuth provider requires the access token to be sent in the query parameters
108+
// instead of the request headers, comment out the next line:
109+
this._oauth2._useAuthorizationHeaderForGET = true;
228110

229-
// Do you want to automatically make somebody an admin? This line might help you do that...
230-
// profile.isAdmin = data.isAdmin ? true : false;
111+
this._oauth2.get(userRoute, accessToken, (err, body/* , res */) => {
112+
if (err) {
113+
return done(err);
114+
}
115+
116+
try {
117+
const json = JSON.parse(body);
118+
const profile = OAuth.parseUserReturn(json);
119+
profile.provider = name;
120+
done(null, profile);
121+
} catch (e) {
122+
done(e);
123+
}
124+
});
125+
};
231126

232-
// Delete or comment out the next TWO (2) lines when you are ready to proceed
233-
process.stdout.write('===\nAt this point, you\'ll need to customise the above section to id, displayName, and emails into the "profile" object.\n===');
234-
return callback(new Error('Congrats! So far so good -- please see server log for details'));
127+
OAuth.parseUserReturn = ({ sub, nickname, picture, email/* , email_verified */ }) => {
128+
const profile = {};
129+
profile.id = sub;
130+
profile.displayName = nickname;
131+
profile.picture = picture;
132+
profile.email = email;
235133

236-
// eslint-disable-next-line
237-
callback(null, profile);
134+
return profile;
238135
};
239136

240137
OAuth.login = async (payload) => {
241-
let uid = await OAuth.getUidByOAuthid(payload.oAuthid);
138+
let uid = await OAuth.getUidByOAuthid(payload.name, payload.oAuthid);
242139
if (uid !== null) {
243140
// Existing User
244-
return ({
245-
uid: uid,
246-
});
141+
return ({ uid });
247142
}
248143

249144
// Check for user via email fallback
250145
uid = await User.getUidByEmail(payload.email);
251146
if (!uid) {
252-
/**
253-
* The email retrieved from the user profile might not be trusted.
254-
* Only you would know — it's up to you to decide whether or not to:
255-
* - Send the welcome email which prompts for verification (default)
256-
* - Bypass the welcome email and automatically verify the email (commented out, below)
257-
*/
258147
const { email } = payload;
259148

260149
// New user
261150
uid = await User.create({
262151
username: payload.handle,
263-
email, // if you uncomment the block below, comment this line out
264152
});
265153

266154
// Automatically confirm user email
267-
// await User.setUserField(uid, 'email', email);
268-
// await UserEmail.confirmByUid(uid);
155+
await User.setUserField(uid, 'email', email);
156+
await User.email.confirmByUid(uid);
269157
}
270158

271159
// Save provider-specific information to the user
272-
await User.setUserField(uid, `${constants.name}Id`, payload.oAuthid);
273-
await db.setObjectField(`${constants.name}Id:uid`, payload.oAuthid, uid);
160+
await User.setUserField(uid, `${payload.name}Id`, payload.oAuthid);
161+
await db.setObjectField(`${payload.name}Id:uid`, payload.oAuthid, uid);
274162

275-
if (payload.isAdmin) {
276-
await Groups.join('administrators', uid);
277-
}
278-
279-
return {
280-
uid: uid,
281-
};
163+
return { uid };
282164
};
283165

284-
OAuth.getUidByOAuthid = async oAuthid => db.getObjectField(`${constants.name}Id:uid`, oAuthid);
166+
OAuth.getUidByOAuthid = async (name, oAuthid) => db.getObjectField(`${name}Id:uid`, oAuthid);
285167

286-
OAuth.deleteUserData = function (data, callback) {
287-
async.waterfall([
288-
async.apply(User.getUserField, data.uid, `${constants.name}Id`),
289-
function (oAuthIdToDelete, next) {
290-
db.deleteObjectField(`${constants.name}Id:uid`, oAuthIdToDelete, next);
291-
},
292-
], (err) => {
293-
if (err) {
294-
winston.error(`[sso-oauth] Could not remove OAuthId data for uid ${data.uid}. Error: ${err}`);
295-
return callback(err);
168+
OAuth.deleteUserData = async (data) => {
169+
const names = await db.getSortedSetMembers('oauth2-multiple:strategies');
170+
const oAuthIds = await User.getUserFields(data.uid, names.map(name => `${name}Id`));
171+
172+
await Promise.all(oAuthIds.map(async (oAuthIdToDelete, idx) => {
173+
if (!oAuthIdToDelete) {
174+
return;
296175
}
297176

298-
callback(null, data);
299-
});
177+
const name = names[idx];
178+
await db.deleteObjectField(`${name}Id:uid`, oAuthIdToDelete);
179+
}));
300180
};
301181

302182
// If this filter is not there, the deleteUserData function will fail when getting the oauthId for deletion.
303-
OAuth.whitelistFields = function (params, callback) {
304-
params.whitelist.push(`${constants.name}Id`);
305-
callback(null, params);
183+
OAuth.whitelistFields = async (params) => {
184+
const names = await db.getSortedSetMembers('oauth2-multiple:strategies');
185+
params.whitelist.push(...names.map(name => `${name}Id`));
186+
187+
return params;
306188
};

plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
{ "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
1111
{ "hook": "static:user.delete", "method": "deleteUserData" },
1212
{ "hook": "filter:user.whitelistFields", "method": "whitelistFields" },
13-
{ "hook": "filter:auth.init", "method": "getStrategy" }
13+
{ "hook": "filter:auth.init", "method": "loadStrategies" }
1414
],
1515
"modules": {
1616
"../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js"

0 commit comments

Comments
 (0)