Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions lib/oauth/http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use strict';

const https = require('https');
const { URL } = require('url');

function hostOf(u) {
try {
return new URL(u).host.toLowerCase();
} catch {
return '';
}
}

function httpsGetJson({ url, accessToken, headers = {} }) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({
method: 'GET',
hostname: u.hostname,
port: u.port || 443,
path: `${u.pathname}${u.search || ''}`,
headers: Object.assign({
'User-Agent': 'nodebb-plugin-sso-oauth2-multiple',
'Authorization': `Bearer ${accessToken}`,
}, headers),
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`${url} -> ${res.statusCode}: ${body.slice(0, 300)}`));
}
try {
resolve(body ? JSON.parse(body) : null);
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.end();
});
}

exports.hostOf = hostOf;
exports.httpsGetJson = httpsGetJson;
22 changes: 22 additions & 0 deletions lib/oauth/id-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict';

const winston = require.main.require('winston');

// Parses the payload section of a JWT without verifying the signature. The
// resulting object exposes the id_token claims (sub, email, name, ...).
function extractIdTokenClaims(idToken) {
if (!idToken || typeof idToken !== 'string') return null;
const parts = idToken.split('.');
if (parts.length < 2) return null;
try {
const payload = Buffer
.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64')
.toString('utf8');
return JSON.parse(payload);
} catch (e) {
winston.warn(`[sso-oauth2-multiple] id_token parse failed: ${e.message}`);
return null;
}
}

exports.extractIdTokenClaims = extractIdTokenClaims;
21 changes: 21 additions & 0 deletions lib/providers/apple.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

// Apple has no userinfo endpoint; profile data comes exclusively from the
// id_token claims, which are merged in by the caller.
module.exports = {
type: 'apple',
pkceDefault: true,
skipUserRoute: true,

matches({ host, name }) {
return /appleid\.apple\.com/.test(host) || /apple/.test(name);
},

parse() {
return {};
},

async fetchEmail() {
return null;
},
};
50 changes: 50 additions & 0 deletions lib/providers/base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

// Default provider: generic OIDC / unknown OAuth2. Also acts as the fallback
// base for providers that only override a subset of hooks.
//
// Every provider module exports the following shape (all fields optional
// except `type` and `parse`):
//
// {
// type: 'github', // lowercase identifier
// pkceDefault: false, // force PKCE on by default
// skipUserRoute: false, // skip userinfo HTTP call
// matches({ host, name, profile }) -> bool, // auto-detection heuristic
// userRouteHeaders() -> object, // extra headers for userinfo
// parse(rawProfile, { idKey }) -> normalized,// shape the profile
// fetchEmail({ strategyConfig, accessToken }) -> { email, verified } | null
// }

module.exports = {
type: 'oidc',

// The generic provider is the fallback; it never claims a match itself.
matches() {
return false;
},

parse(profile, { idKey } = {}) {
const {
id, sub,
name, nickname, preferred_username: preferredUsername, login,
given_name: givenName, middle_name: middleName, family_name: familyName,
picture, avatar_url: avatarUrl, email, email_verified: emailVerified,
} = profile;
const displayName = nickname || preferredUsername || login || name;
const combined = [givenName, middleName, familyName].filter(Boolean).join(' ');
return {
id: (idKey && profile[idKey]) || id || sub,
displayName,
fullname: name || combined || displayName,
picture: picture || avatarUrl,
email,
email_verified: emailVerified,
username: login || preferredUsername || nickname,
};
},

async fetchEmail() {
return null;
},
};
38 changes: 38 additions & 0 deletions lib/providers/bitbucket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict';

const { httpsGetJson } = require('../oauth/http');

function pickBitbucketEmail(payload) {
const list = payload && Array.isArray(payload.values) ? payload.values : null;
if (!list) return null;
const p = list.find(i => i.is_primary && i.is_confirmed) ||
list.find(i => i.is_primary) ||
list.find(i => i.is_confirmed) ||
list[0];
return p ? { email: p.email, verified: !!p.is_confirmed } : null;
}

module.exports = {
type: 'bitbucket',

matches({ host, name }) {
return host === 'api.bitbucket.org' || /bitbucket/.test(name);
},

parse(profile) {
return {
id: profile.uuid || profile.account_id || profile.username,
displayName: profile.username || profile.display_name,
fullname: profile.display_name || profile.username,
picture: profile.links && profile.links.avatar && profile.links.avatar.href,
username: profile.username,
};
},

async fetchEmail({ accessToken }) {
return pickBitbucketEmail(await httpsGetJson({
url: 'https://api.bitbucket.org/2.0/user/emails',
accessToken,
}));
},
};
34 changes: 34 additions & 0 deletions lib/providers/discord.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

const { httpsGetJson } = require('../oauth/http');

module.exports = {
type: 'discord',

matches({ host, name }) {
return host === 'discord.com' || host === 'discordapp.com' || /discord/.test(name);
},

parse(profile) {
const avatar = profile.avatar ?
`https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` :
undefined;
return {
id: profile.id,
displayName: profile.global_name || profile.username,
fullname: profile.global_name || profile.username,
picture: avatar,
email: profile.email || undefined,
email_verified: typeof profile.verified === 'boolean' ? profile.verified : undefined,
username: profile.username,
};
},

async fetchEmail({ accessToken }) {
const json = await httpsGetJson({
url: 'https://discord.com/api/users/@me',
accessToken,
});
return json && json.email ? { email: json.email, verified: !!json.verified } : null;
},
};
31 changes: 31 additions & 0 deletions lib/providers/facebook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

const { httpsGetJson } = require('../oauth/http');

module.exports = {
type: 'facebook',

matches({ host, name }) {
return host === 'graph.facebook.com' || /facebook|meta/.test(name);
},

parse(profile) {
const picture = profile.picture && profile.picture.data && profile.picture.data.url;
return {
id: profile.id,
displayName: profile.name,
fullname: profile.name,
picture,
email: profile.email || undefined,
email_verified: profile.email ? true : undefined,
};
},

async fetchEmail({ accessToken }) {
const json = await httpsGetJson({
url: 'https://graph.facebook.com/v19.0/me?fields=id,email',
accessToken,
});
return json && json.email ? { email: json.email, verified: true } : null;
},
};
52 changes: 52 additions & 0 deletions lib/providers/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

const { httpsGetJson } = require('../oauth/http');

const GITHUB_USER_HEADERS = {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};

function pickGitHubEmail(list) {
if (!Array.isArray(list)) return null;
const p = list.find(i => i.primary && i.verified) ||
list.find(i => i.primary) ||
list.find(i => i.verified) ||
list[0];
return p ? { email: p.email, verified: !!p.verified } : null;
}

module.exports = {
type: 'github',

matches({ host, name, profile }) {
if (host === 'api.github.com' || /github/.test(name)) return true;
if (profile && (profile.login !== undefined || profile.html_url || profile.gravatar_id)) {
return true;
}
return false;
},

userRouteHeaders() {
return GITHUB_USER_HEADERS;
},

parse(profile) {
return {
id: profile.id,
displayName: profile.login || profile.name,
fullname: profile.name || profile.login,
picture: profile.avatar_url,
email: profile.email || undefined,
username: profile.login,
};
},

async fetchEmail({ accessToken }) {
return pickGitHubEmail(await httpsGetJson({
url: 'https://api.github.com/user/emails',
accessToken,
headers: GITHUB_USER_HEADERS,
}));
},
};
49 changes: 49 additions & 0 deletions lib/providers/gitlab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict';

const { URL } = require('url');
const { httpsGetJson } = require('../oauth/http');

function gitlabApiBase(userRoute) {
try {
const u = new URL(userRoute);
const m = u.pathname.match(/^(.*\/api\/v4)\b/);
return `${u.protocol}//${u.host}${m ? m[1] : '/api/v4'}`;
} catch {
return 'https://gitlab.com/api/v4';
}
}

module.exports = {
type: 'gitlab',

matches({ profile }, strategyConfig) {
if (strategyConfig && strategyConfig.userRoute && /\/api\/v4\/user\b/.test(strategyConfig.userRoute)) {
return true;
}
if (profile && (profile.web_url || profile.avatar_url) &&
profile.username && profile.id && profile.state) {
return true;
}
return false;
},

parse(profile) {
return {
id: profile.id,
displayName: profile.username || profile.name,
fullname: profile.name || profile.username,
picture: profile.avatar_url,
email: profile.email || profile.public_email || undefined,
email_verified: profile.confirmed_at ? true : undefined,
username: profile.username,
};
},

async fetchEmail({ strategyConfig, accessToken }) {
const base = gitlabApiBase(strategyConfig.userRoute);
const json = await httpsGetJson({ url: `${base}/user`, accessToken });
return json && json.email ? { email: json.email, verified: !!json.confirmed_at } : null;
},
};

module.exports.gitlabApiBase = gitlabApiBase;
32 changes: 32 additions & 0 deletions lib/providers/google.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict';

const { httpsGetJson } = require('../oauth/http');

module.exports = {
type: 'google',

matches({ host, name }) {
if (host === 'openidconnect.googleapis.com' || host === 'www.googleapis.com') return true;
return /google/.test(name);
},

// /v1/userinfo: { sub, name, given_name, family_name, picture, email, email_verified, locale }
parse(profile) {
return {
id: profile.sub,
displayName: profile.name || (profile.email ? profile.email.split('@')[0] : undefined),
fullname: profile.name,
picture: profile.picture,
email: profile.email,
email_verified: profile.email_verified,
};
},

async fetchEmail({ accessToken }) {
const json = await httpsGetJson({
url: 'https://openidconnect.googleapis.com/v1/userinfo',
accessToken,
});
return json && json.email ? { email: json.email, verified: !!json.email_verified } : null;
},
};
Loading