diff --git a/package-lock.json b/package-lock.json index ea0df219f..d3cdd2206 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2080,6 +2080,16 @@ "@types/express": "*" } }, + "@types/passport-facebook": { + "version": "2.1.8", + "resolved": "http://registry.npmjs.org/@types/passport-facebook/-/passport-facebook-2.1.8.tgz", + "integrity": "sha512-5FGF6zNN0ZELetEdIDjVjfHSJfXSehNWeRLv9/8JD6Des4Z9A7sthhyXVRQUXeUxv0SmQ/i+IHZjR8R/G61wIg==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/passport-local": { "version": "1.0.33", "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.33.tgz", @@ -18709,6 +18719,14 @@ "pause": "0.0.1" } }, + "passport-facebook": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-2.1.1.tgz", + "integrity": "sha1-w50LUq5NWRYyRaTiGnubYyEwMxE=", + "requires": { + "passport-oauth2": "1.x.x" + } + }, "passport-local": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", diff --git a/package.json b/package.json index d03269e3d..73a7d187d 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "nodemailer": "^4.6.7", "nunjucks": "^3.1.3", "passport": "^0.4.0", + "passport-facebook": "^2.1.1", "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-strategy": "^1.0.0", @@ -149,6 +150,7 @@ "@types/nodemailer": "^4.6.2", "@types/nunjucks": "^3.0.0", "@types/passport": "^0.4.6", + "@types/passport-facebook": "^2.1.8", "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 207fb1e92..5763eebc9 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -6,6 +6,7 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; import { Config } from "talk-common/config"; +import FacebookStrategy from "talk-server/app/middleware/passport/strategies/facebook"; import { JWTStrategy } from "talk-server/app/middleware/passport/strategies/jwt"; import { createLocalStrategy } from "talk-server/app/middleware/passport/strategies/local"; import OIDCStrategy from "talk-server/app/middleware/passport/strategies/oidc"; @@ -50,6 +51,9 @@ export function createPassport( // Use the SSOStrategy. auth.use(new JWTStrategy(options)); + // Use the FacebookStrategy. + auth.use(new FacebookStrategy(options)); + return auth; } diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts new file mode 100644 index 000000000..7fd628635 --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -0,0 +1,203 @@ +import { Db } from "mongodb"; +import { + Profile, + Strategy as FacebookPassportStrategy, +} from "passport-facebook"; +import { VerifyCallback } from "passport-oauth2"; +import { Strategy } from "passport-strategy"; + +import { Config } from "talk-common/config"; +import { reconstructTenantURL } from "talk-server/app/url"; +import { + GQLFacebookAuthIntegration, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { + FacebookProfile, + retrieveUserWithProfile, +} from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter"; +import { upsert } from "talk-server/services/users"; +import { Request } from "talk-server/types/express"; + +async function findOrCreateFacebookUser( + mongo: Db, + tenant: Tenant, + integration: GQLFacebookAuthIntegration, + { id, photos, emails, displayName }: Profile +) { + // Create the user profile that will be used to lookup the User. + const profile: FacebookProfile = { + type: "facebook", + id, + }; + + let user = await retrieveUserWithProfile(mongo, tenant.id, profile); + if (!user) { + if (!integration.allowRegistration) { + // Registration is disabled, so we can't create the user user here. + return; + } + + // FIXME: implement rules. + + // Try to get the avatar. + let avatar: string | undefined; + if (photos && photos.length > 0) { + avatar = photos[0].value; + } + + // Try to get the email address. + let email: string | undefined; + let emailVerified: boolean | undefined; + if (emails && emails.length > 0) { + email = emails[0].value; + emailVerified = false; + } + + user = await upsert(mongo, tenant, { + username: null, + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified: emailVerified, + avatar, + profiles: [profile], + }); + } + + return user; +} + +function getEnabledIntegration( + tenant: Tenant +): Required { + const integration = tenant.auth.integrations.facebook; + + // Handle when the integration is enabled/disabled. + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + + if (!integration.clientID) { + throw new Error("clientID is missing in configuration"); + } + + if (!integration.clientSecret) { + throw new Error("clientSecret is missing in configuration"); + } + + // TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type. + return integration as Required; +} + +export interface FacebookStrategyOptions { + config: Config; + mongo: Db; + tenantCache: TenantCache; +} + +export default class FacebookStrategy extends Strategy { + public name = "facebook"; + + private config: Config; + private mongo: Db; + private cache: TenantCacheAdapter; + + constructor({ config, mongo, tenantCache }: FacebookStrategyOptions) { + super(); + + this.config = config; + this.mongo = mongo; + this.cache = new TenantCacheAdapter(tenantCache); + } + + private userAuthenticatedCallback = async ( + req: Request, + accessToken: string, + refreshToken: string, + profile: Profile, + done: VerifyCallback + ) => { + const { tenant } = req.talk!; + if (!tenant) { + // TODO: return a better error. + throw new Error("tenant not found"); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + let integration: Required; + try { + integration = getEnabledIntegration(tenant); + } catch (err) { + // TODO: wrap error? + return done(err); + } + + try { + const user = await findOrCreateFacebookUser( + this.mongo, + tenant, + integration, + profile + ); + + return done(null, user); + } catch (err) { + return done(err); + } + }; + + public authenticate(req: Request) { + try { + const { tenant } = req.talk!; + if (!tenant) { + // TODO: return a better error. + throw new Error("tenant not found"); + } + + // Get the integration (it will throw if the integration is not enabled). + const integration = getEnabledIntegration(tenant); + + let strategy = this.cache.get(tenant.id); + if (!strategy) { + strategy = new FacebookPassportStrategy( + { + clientID: integration.clientID, + clientSecret: integration.clientSecret, + callbackURL: reconstructTenantURL( + this.config, + tenant, + "/api/tenant/auth/facebook/callback" + ), + profileFields: ["id", "displayName", "photos", "email"], + enableProof: true, + passReqToCallback: true, + }, + this.userAuthenticatedCallback + ); + + this.cache.set(tenant.id, strategy); + } + + // Augment the strategy with the request method bindings. + strategy.error = this.error.bind(this); + strategy.fail = this.fail.bind(this); + strategy.pass = this.pass.bind(this); + strategy.redirect = this.redirect.bind(this); + strategy.success = this.success.bind(this); + + strategy.authenticate(req, { session: false }); + } catch (err) { + return this.error(err); + } + } +} diff --git a/src/core/server/app/router/api/auth.ts b/src/core/server/app/router/api/auth.ts index 37a1ddfff..0bc04a590 100644 --- a/src/core/server/app/router/api/auth.ts +++ b/src/core/server/app/router/api/auth.ts @@ -29,6 +29,16 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) { signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); + router.get( + "/facebook", + wrapAuthn(options.passport, app.signingConfig, "facebook") + ); + + router.get( + "/facebook/callback", + wrapAuthn(options.passport, app.signingConfig, "facebook") + ); + router.get( "/oidc/:oidcID", wrapAuthn(options.passport, app.signingConfig, "oidc") diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index a89d3b42a..e29a54134 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -32,7 +32,12 @@ export interface SSOProfile { id: string; } -export type Profile = LocalProfile | OIDCProfile | SSOProfile; +export interface FacebookProfile { + type: "facebook"; + id: string; +} + +export type Profile = LocalProfile | OIDCProfile | SSOProfile | FacebookProfile; export interface Token { readonly id: string;