diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts index 7fd628635..3ae0a7f55 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -1,14 +1,11 @@ import { Db } from "mongodb"; -import { - Profile, - Strategy as FacebookPassportStrategy, -} from "passport-facebook"; -import { VerifyCallback } from "passport-oauth2"; -import { Strategy } from "passport-strategy"; +import { Profile, Strategy } from "passport-facebook"; import { Config } from "talk-common/config"; +import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2"; import { reconstructTenantURL } from "talk-server/app/url"; import { + GQLAuthIntegrations, GQLFacebookAuthIntegration, GQLUSER_ROLE, } from "talk-server/graph/tenant/schema/__generated__/types"; @@ -18,85 +15,7 @@ import { 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; @@ -104,100 +23,81 @@ export interface FacebookStrategyOptions { tenantCache: TenantCache; } -export default class FacebookStrategy extends Strategy { +export default class FacebookStrategy extends OAuth2Strategy< + GQLFacebookAuthIntegration, + Strategy +> { public name = "facebook"; - private config: Config; - private mongo: Db; - private cache: TenantCacheAdapter; + protected getIntegration = (integrations: GQLAuthIntegrations) => + integrations.facebook; - constructor({ config, mongo, tenantCache }: FacebookStrategyOptions) { - super(); + protected async findOrCreateUser( + tenant: Tenant, + integration: Required, + { id, photos, emails, displayName }: Profile + ) { + // Create the user profile that will be used to lookup the User. + const profile: FacebookProfile = { + type: "facebook", + id, + }; - this.config = config; - this.mongo = mongo; - this.cache = new TenantCacheAdapter(tenantCache); + let user = await retrieveUserWithProfile(this.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(this.mongo, tenant, { + username: null, + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified: emailVerified, + avatar, + profiles: [profile], + }); + } + + return user; } - 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); - } + protected createStrategy( + tenant: Tenant, + integration: Required + ) { + return new Strategy( + { + 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.verifyCallback + ); } } diff --git a/src/core/server/app/middleware/passport/strategies/oauth2.ts b/src/core/server/app/middleware/passport/strategies/oauth2.ts new file mode 100644 index 000000000..e7f74c685 --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -0,0 +1,122 @@ +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; + +import { Profile } from "passport"; +import { VerifyCallback } from "passport-oauth2"; +import { Config } from "talk-common/config"; +import { GQLAuthIntegrations } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter"; +import { Request } from "talk-server/types/express"; + +interface OAuth2Integration { + enabled: boolean; + clientID?: string; + clientSecret?: string; +} + +export interface OAuth2StrategyOptions { + config: Config; + mongo: Db; + tenantCache: TenantCache; +} + +export default abstract class OAuth2Strategy< + T extends OAuth2Integration, + U extends Strategy +> extends Strategy { + protected config: Config; + protected mongo: Db; + protected cache: TenantCacheAdapter; + + constructor({ config, mongo, tenantCache }: OAuth2StrategyOptions) { + super(); + + this.config = config; + this.mongo = mongo; + this.cache = new TenantCacheAdapter(tenantCache); + } + + protected abstract getIntegration(integrations: GQLAuthIntegrations): T; + + protected abstract createStrategy( + tenant: Tenant, + integration: Required + ): U; + + protected abstract findOrCreateUser( + tenant: Tenant, + integration: Required, + profile: Profile + ): Promise; + + protected verifyCallback = async ( + req: Request, + accessToken: string, + refreshToken: string, + profile: Profile, + done: VerifyCallback + ) => { + try { + // Talk is defined at this point. + const tenant = req.talk!.tenant!; + + // Get the integration. + const integration = this.getIntegration(tenant.auth.integrations); + + // Get the user. + const user = await this.findOrCreateUser( + tenant, + integration as Required, + profile + ); + + return done(null, user); + } catch (err) { + return done(err); + } + }; + + public authenticate(req: Request) { + try { + // Talk is defined at this point. + const tenant = req.talk!.tenant!; + + // Get the integration. + const integration = this.getIntegration(tenant.auth.integrations); + + // Check to see if the integration is 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"); + } + + let strategy = this.cache.get(tenant.id); + if (!strategy) { + strategy = this.createStrategy(tenant, integration as Required); + 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); + } + } +}