@ladjs/passport
Version:
Passport for Lad
581 lines (528 loc) • 19.3 kB
JavaScript
const crypto = require('node:crypto');
const fs = require('node:fs');
const process = require('node:process');
const AppleStrategy = require('@nicokaiser/passport-apple').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const GoogleStrategy = require('passport-google-oauth20');
const UbuntuStrategy = require('passport-ubuntu').Strategy;
const OtpStrategy = require('@ladjs/passport-otp-strategy').Strategy;
const WebAuthnStrategy = require('@forwardemail/passport-fido2-webauthn');
const _ = require('lodash');
const isSANB = require('is-string-and-not-blank');
const validator = require('validator');
const { KoaPassport } = require('koa-passport');
const { boolean } = require('boolean');
const { SessionChallengeStore } = WebAuthnStrategy;
const store = new SessionChallengeStore();
const PASSPORT_FIELDS = {
lastLoginField: 'last_login_at',
displayName: 'display_name',
givenName: 'given_name',
familyName: 'family_name',
avatarURL: 'avatar_url',
// apple
appleProfileID: 'apple_profile_id',
appleAccessToken: 'apple_access_token',
appleRefreshToken: 'apple_refresh_token',
// google
googleProfileID: 'google_profile_id',
googleAccessToken: 'google_access_token',
googleRefreshToken: 'google_refresh_token',
// github
githubProfileID: 'github_profile_id',
githubAccessToken: 'github_access_token',
githubRefreshToken: 'github_refresh_token',
// ubuntu
ubuntuProfileID: 'ubuntu_profile_id',
ubuntuUsername: 'ubuntu_username',
// otp
otpToken: 'otp_token',
otpEnabled: 'otp_enabled'
};
const PASSPORT_PHRASES = {
INVALID_USER: 'Invalid user response, please try again.',
INVALID_PROFILE_RESPONSE:
'Invalid profile response, please delete this site from your third-party sign-in preferences and try again.',
INVALID_EMAIL:
'Invalid email address, please delete this site from your third-party sign-in preferences and try again.',
INVALID_PROFILE_ID:
'Invalid profile identifier, please delete this site from your third-party sign-in preferences and try again.',
CONSENT_REQUIRED:
'Offline access consent required to generate a new refresh token.',
OTP_NOT_ENABLED: 'OTP authentication is not enabled.',
OTP_TOKEN_DOES_NOT_EXIST: 'OTP token does not exist for validation.',
INVALID_WEBAUTHN_KEY: 'Invalid WebAuthn key.'
};
class Passport extends KoaPassport {
constructor(config = {}, Users) {
super();
this.getEmailFromProfile = this.getEmailFromProfile.bind(this);
this.loginOrCreateProfile = this.loginOrCreateProfile.bind(this);
this.updateAndSaveUser = this.updateAndSaveUser.bind(this);
this.config = _.defaultsDeep(config, {
providers: {
local: boolean(process.env.AUTH_LOCAL_ENABLED),
apple: boolean(process.env.AUTH_APPLE_ENABLED),
google: boolean(process.env.AUTH_GOOGLE_ENABLED),
github: boolean(process.env.AUTH_GITHUB_ENABLED),
otp: boolean(process.env.AUTH_OTP_ENABLED),
webauthn: boolean(process.env.AUTH_WEBAUTHN_ENABLED),
ubuntu: boolean(process.env.AUTH_UBUNTU_ENABLED)
},
strategies: {
apple: {
clientID: process.env.APPLE_CLIENT_ID,
teamID: process.env.APPLE_TEAM_ID,
keyID: process.env.APPLE_KEY_ID,
key: isSANB(process.env.APPLE_KEY_PATH)
? fs.readFileSync(process.env.APPLE_KEY_PATH)
: process.env.APPLE_KEY_PATH,
callbackURL: process.env.APPLE_CALLBACK_URL,
scope: ['name', 'email']
},
google: {
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'
]
},
github: {
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: process.env.GITHUB_CALLBACK_URL,
scope: ['user:email']
},
otp: {
codeField: process.env.OTP_CODE_FIELD || 'passcode',
// `authenticator` options passed through to `otplib`
// <https://github.com/yeojz/otplib>
authenticator: {
crypto,
step: 30,
// allow last and current totp passcode
window: 1
}
},
webauthn: {
store
// <https://github.com/jaredhanson/passport-webauthn/pull/4>
// passReqToCallback: true
},
ubuntu: {
returnURL: process.env.UBUNTU_CALLBACK_URL,
realm: process.env.UBUNTU_REALM,
stateless: true
}
},
fields: PASSPORT_FIELDS,
phrases: PASSPORT_PHRASES
});
if (_.isObject(Users) && _.isFunction(Users.findOne)) {
this.serializeUser((user, done) => {
done(null, user.id);
});
this.deserializeUser((id, done) => {
Users.findOne({ id }, (err, user) => {
if (err) return done(err);
// if no user exists then invalidate the previous session
// <https://github.com/jaredhanson/passport/issues/6#issuecomment-4857287>
done(null, user || false);
});
});
if (this.config.providers.local) {
if (!_.isFunction(Users.createStrategy))
throw new Error(
'Local strategy configured but Users.createStrategy method is missing'
);
this.use(Users.createStrategy());
}
//
// ubuntu (openid)
// <https://help.ubuntu.com/community/SSO/OpenID%20integration>
// <https://gitlab.com/theopenstore/passport-ubuntu>
//
//
if (this.config.providers.ubuntu)
this.use(
new UbuntuStrategy(
this.config.strategies.ubuntu,
this.loginOrCreateProfile(Users, 'ubuntu')
)
);
// apple
if (this.config.providers.apple)
this.use(
new AppleStrategy(
this.config.strategies.apple,
this.loginOrCreateProfile(Users, 'apple')
)
);
// github
if (this.config.providers.github)
this.use(
new GitHubStrategy(
this.config.strategies.github,
this.loginOrCreateProfile(Users, 'github')
)
);
// google
if (this.config.providers.google)
this.use(
new GoogleStrategy(
this.config.strategies.google,
this.loginOrCreateProfile(Users, 'google')
)
);
// webauthn
if (this.config.providers.webauthn)
this.use(
new WebAuthnStrategy(
this.config.strategies.webauthn,
(credentialId, userHandle, done) => {
Users.findOne(
{
'passkeys.credentialId': credentialId
},
(err, user) => {
if (err) return done(err);
if (!user)
return done(
new Error(this.config.phrases.INVALID_WEBAUTHN_KEY)
);
if (
!Array.isArray(user.passkeys) ||
user.passkeys.length === 0
)
return done(
new Error(this.config.phrases.INVALID_WEBAUTHN_KEY)
);
const match = user.passkeys.find(
(p) => p.credentialId === credentialId
);
if (!match)
return done(
new Error(this.config.phrases.INVALID_WEBAUTHN_KEY)
);
// if (Buffer.compare(Buffer.from(user.id), userHandle) !== 0)
if (user.id !== userHandle.toString('base64'))
return done(
new Error(this.config.phrases.INVALID_WEBAUTHN_KEY)
);
done(null, user.toObject(), match.publicKey);
}
);
},
//
// NOTE: note we don't allow creation without a user
// (unlike passport-fido2-webauthn default example)
//
(user, id, publicKey, done) => {
return done(new Error(this.config.phrases.INVALID_WEBAUTHN_KEY));
// done(null, user.toObject());
}
)
);
}
// otp
if (this.config.providers.otp) {
// validate first factor auth enabled
const enabledFirstFactor = Object.keys(this.config.providers).filter(
(provider) =>
(provider !== 'otp' && this.config.providers[provider] === 'true') ||
this.config.providers[provider] === true
);
if (enabledFirstFactor.length <= 1)
throw new Error('No first factor authentication strategy enabled');
this.use(
new OtpStrategy(this.config.strategies.otp, (user, done) => {
// if otp is not enabled
if (!user[this.config.fields.otpEnabled])
return done(new Error(this.config.phrases.OTP_NOT_ENABLED));
// we already have the user object from initial login
if (!user[this.config.fields.otpToken])
return done(
new Error(this.config.phrases.OTP_TOKEN_DOES_NOT_EXIST)
);
done(null, user[this.config.fields.otpToken]);
})
);
}
}
getEmailFromProfile(provider, profile) {
if (
provider === 'ubuntu' &&
_.isArray(profile.email) &&
!_.isEmpty(profile.email) &&
validator.isEmail(profile.email[0])
)
return profile.email[0];
if (
provider === 'apple' &&
_.isString(profile.email) &&
validator.isEmail(profile.email)
)
return profile.email;
if (!_.isArray(profile.emails)) return;
const match = profile.emails.find(
(obj) =>
_.isObject(obj) && _.isString(obj.value) && validator.isEmail(obj.value)
);
if (match) return match.value;
}
loginOrCreateProfile(Users, provider) {
return (accessToken, refreshToken, profile, done) => {
if (provider === 'ubuntu') {
profile = refreshToken;
if (_.isObject(profile) && isSANB(profile.claimedIdentifier))
profile.id = profile.claimedIdentifier;
if (_.isArray(profile.fullname) && isSANB(profile.fullname[0]))
profile.displayName = profile.fullname[0];
accessToken = null;
refreshToken = null;
if (!_.isArray(profile.nickname) || !isSANB(profile.nickname[0]))
return done(new Error(this.config.phrases.INVALID_PROFILE_ID));
}
if (!_.isObject(profile))
return done(new Error(this.config.phrases.INVALID_PROFILE_RESPONSE));
if (!isSANB(profile.id))
return done(new Error(this.config.phrases.INVALID_PROFILE_ID));
//
// NOTE: we lookup by profile ID in case the email address changed at the provider
//
Users.findOne(
{ [this.config.fields[`${provider}ProfileID`]]: profile.id },
(err, user) => {
if (err) return done(err);
//
// NOTE: this assumes that the user with that profile ID was not found
// so we will need to create a new user, but we can only do that
// if the login profile has a supplied email address
// and if not, then we need to inform user to revoke authorization
// for this application and then attempt to sign in again for email retrieval
// (e.g. "Delete this site from your sign in with $provider preferences")
// <https://github.com/nicokaiser/passport-apple/issues/3>
//
// store a boolean whether we need to save or not
let save = false;
// parse the email from the profile
const email = this.getEmailFromProfile(provider, profile);
// continue along
if (user)
return this.updateAndSaveUser(
provider,
accessToken,
refreshToken,
profile,
save,
user,
email,
done
);
// this will get the first match (but is dummy-proof)
if (!email) return done(new Error(this.config.phrases.INVALID_EMAIL));
//
// find or create the new user
//
Users.findOne({ email }, (err, user) => {
if (err) return done(err);
if (!user) {
user = new Users({
email,
[this.config.fields[`${provider}ProfileID`]]: profile.id
});
save = true;
}
// continue along
this.updateAndSaveUser(
provider,
accessToken,
refreshToken,
profile,
save,
user,
email,
done
);
});
}
);
};
}
// eslint-disable-next-line complexity, max-params
updateAndSaveUser(
provider,
accessToken,
refreshToken,
profile,
save,
user,
email,
done
) {
//
// update or set user name and photo
//
// (but only if `save` was already true, e.g. first sign in)
// (we don't want to update user's info if they deleted it)
// (and then they sign in again and it's auto-repopulated)
//
if (save) {
//
// name
//
if (provider === 'apple') {
// profile.name = { firstName, lastName }
if (_.isObject(profile.name)) {
if (isSANB(profile.name.firstName))
user[this.config.fields.givenName] = profile.name.firstName;
if (isSANB(profile.name.lastName))
user[this.config.fields.familyName] = profile.name.lastName;
}
} else {
//
// google and github strategies respect this naming convention
// (we don't want to override values if they were already set though)
//
for (const key of ['displayName', 'givenName', 'familyName']) {
if (isSANB(profile[key]) && !isSANB(user[this.config.fields[key]]))
user[this.config.fields[key]] = profile[key];
}
// ubuntu
if (
provider === 'ubuntu' &&
_.isArray(profile.nickname) &&
isSANB(profile.nickname[0])
)
user[this.config.fields.ubuntuUsername] = profile.nickname[0];
//
// google photo
// (we don't want to override values if they were already set though)
//
if (
provider === 'google' &&
(!_.isString(user[this.config.fields.avatarURL]) ||
!validator.isURL(user[this.config.fields.avatarURL])) &&
_.isObject(profile._json) &&
_.isObject(profile._json.image) &&
_.isString(profile._json.image.url) &&
validator.isURL(profile._json.image.url)
) {
// we don't want ?sz= in the image URL
user[this.config.fields.avatarURL] =
profile._json.image.url.split('?sz=')[0];
}
//
// github photo
// (we don't want to override values if they were already set though)
//
if (provider === 'github') {
const photoMatch = _.isArray(profile.photos)
? profile.photos.find(
(photo) =>
_.isObject(photo) &&
_.isString(photo.value) &&
validator.isURL(photo.value)
)
: false;
if (
(!_.isString(user[this.config.fields.avatarURL]) ||
!validator.isURL(user[this.config.fields.avatarURL])) &&
photoMatch
)
user[this.config.fields.avatarURL] = photoMatch.value;
}
}
}
//
// handle edge case in which email was not set but we had a profile
// (this would only happen for users that had profile.id set but no email)
// (e.g. some accidental delete of the user.email field)
//
if (!user.email && email) {
save = true;
user.email = email;
}
// if ubuntu then update nickname if not set already
if (
provider === 'ubuntu' &&
_.isArray(profile.nickname) &&
isSANB(profile.nickname[0]) &&
profile.nickname[0] !== user[this.config.fields.ubuntuUsername]
) {
save = true;
user[this.config.fields.ubuntuUsername] = profile.nickname[0];
}
// update or set access token
if (
isSANB(accessToken) &&
user[this.config.fields[`${provider}AccessToken`]] !== accessToken
) {
save = true;
user[this.config.fields[`${provider}AccessToken`]] = accessToken;
}
// update or set refresh token
if (
isSANB(refreshToken) &&
user[this.config.fields[`${provider}RefreshToken`]] !== refreshToken
) {
save = true;
user[this.config.fields[`${provider}RefreshToken`]] = refreshToken;
}
// update or set profile.id (in the rare edge case it could have changed)
if (user[this.config.fields[`${provider}ProfileID`]] !== profile.id) {
save = true;
user[this.config.fields[`${provider}ProfileID`]] = profile.id;
}
// update the last login for the user (matches passport-local-mongoose behavior)
if (this.config.fields.lastLoginField) {
save = true;
user[this.config.fields.lastLoginField] = new Date();
}
//
// NOTE: below we have some logic next to comments that say
// "support google consent issue" and this is related to a bug
// that doesn't let us revoke tokens in order for us to get a new refresh token
// (see <http://stackoverflow.com/a/18578660>)
// so what we do is explicitly send them to a google URL with `prompt=consent`
// (see the Lad codebase for an example of this and the redirect to /auth/google/consent)
// (if and only if the `err.consent_required` property exists and is truthy)
//
//
// we only want to call save if it was a new user or if there were actual changes
// otherwise this is a useless db operation and affects performance
//
if (!save) {
//
// support google consent issue
//
if (provider === 'google' && !isSANB(refreshToken)) {
const err = new Error(this.config.phrases.CONSENT_REQUIRED);
err.consent_required = true;
return done(err);
}
return done(null, user.toObject());
}
user.save((err, user) => {
if (err) return done(err);
//
// dummy-proofing
//
if (!user) return done(new Error(this.config.phrases.INVALID_USER));
//
// support google consent issue
//
if (provider === 'google' && !isSANB(refreshToken)) {
const err = new Error(this.config.phrases.CONSENT_REQUIRED);
err.consent_required = true;
return done(err);
}
done(null, user.toObject());
});
}
}
Passport.DEFAULT_PHRASES = PASSPORT_PHRASES;
Passport.DEFAULT_FIELDS = PASSPORT_FIELDS;
module.exports = Passport;