diff --git a/package-lock.json b/package-lock.json index d3cdd2206..8b5dbf36b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18727,6 +18727,14 @@ "passport-oauth2": "1.x.x" } }, + "passport-google-oauth2": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.1.6.tgz", + "integrity": "sha1-39cBasdEn+J8/rJSrpdK/CMleg0=", + "requires": { + "passport-oauth2": "^1.1.2" + } + }, "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 73a7d187d..127a62e0d 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "nunjucks": "^3.1.3", "passport": "^0.4.0", "passport-facebook": "^2.1.1", + "passport-google-oauth2": "^0.1.6", "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-strategy": "^1.0.0", diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 5763eebc9..85cf5c8fa 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -7,6 +7,7 @@ import passport, { Authenticator } from "passport"; import { Config } from "talk-common/config"; import FacebookStrategy from "talk-server/app/middleware/passport/strategies/facebook"; +import GoogleStrategy from "talk-server/app/middleware/passport/strategies/google"; 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"; @@ -54,6 +55,9 @@ export function createPassport( // Use the FacebookStrategy. auth.use(new FacebookStrategy(options)); + // Use the GoogleStrategy. + auth.use(new GoogleStrategy(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 index 3ae0a7f55..c0f202737 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -77,6 +77,8 @@ export default class FacebookStrategy extends OAuth2Strategy< }); } + // TODO: maybe update user details? + return user; } diff --git a/src/core/server/app/middleware/passport/strategies/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts new file mode 100644 index 000000000..9e793e722 --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/google.ts @@ -0,0 +1,116 @@ +import { Db } from "mongodb"; +import { Profile, Strategy } from "passport-google-oauth2"; + +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, + GQLGoogleAuthIntegration, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { + GoogleProfile, + retrieveUserWithProfile, +} from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { upsert } from "talk-server/services/users"; + +export interface GoogleStrategyOptions { + config: Config; + mongo: Db; + tenantCache: TenantCache; +} + +export interface GoogleStrategyOptions { + config: Config; + mongo: Db; + tenantCache: TenantCache; +} + +export default class GoogleStrategy extends OAuth2Strategy< + GQLGoogleAuthIntegration, + Strategy +> { + public name = "google"; + + constructor(options: GoogleStrategyOptions) { + super({ + ...options, + scope: ["profile"], + }); + } + + protected getIntegration = (integrations: GQLAuthIntegrations) => + integrations.google; + + 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: GoogleProfile = { + type: "google", + id, + }; + + 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], + }); + } + + // TODO: maybe update user details? + + return user; + } + + protected createStrategy( + tenant: Tenant, + integration: Required + ) { + return new Strategy( + { + clientID: integration.clientID, + clientSecret: integration.clientSecret, + callbackURL: reconstructTenantURL( + this.config, + tenant, + "/api/tenant/auth/google/callback" + ), + 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 index e7f74c685..dbcdcfe89 100644 --- a/src/core/server/app/middleware/passport/strategies/oauth2.ts +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -21,6 +21,7 @@ export interface OAuth2StrategyOptions { config: Config; mongo: Db; tenantCache: TenantCache; + scope?: string[]; } export default abstract class OAuth2Strategy< @@ -30,13 +31,15 @@ export default abstract class OAuth2Strategy< protected config: Config; protected mongo: Db; protected cache: TenantCacheAdapter; + private scope?: string[]; - constructor({ config, mongo, tenantCache }: OAuth2StrategyOptions) { + constructor({ config, mongo, tenantCache, scope }: OAuth2StrategyOptions) { super(); this.config = config; this.mongo = mongo; this.cache = new TenantCacheAdapter(tenantCache); + this.scope = scope; } protected abstract getIntegration(integrations: GQLAuthIntegrations): T; @@ -114,7 +117,10 @@ export default abstract class OAuth2Strategy< strategy.redirect = this.redirect.bind(this); strategy.success = this.success.bind(this); - strategy.authenticate(req, { session: false }); + strategy.authenticate(req, { + session: false, + scope: this.scope, + }); } 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 0bc04a590..e29748e5a 100644 --- a/src/core/server/app/router/api/auth.ts +++ b/src/core/server/app/router/api/auth.ts @@ -8,16 +8,30 @@ import { import { wrapAuthn } from "talk-server/app/middleware/passport"; import { RouterOptions } from "talk-server/app/router/types"; +function wrapPath( + app: AppOptions, + options: RouterOptions, + router: express.Router, + strategy: string, + path: string = `/${strategy}` +) { + const handler = wrapAuthn(options.passport, app.signingConfig, strategy); + + router.get(path, handler); + router.get(path + "/callback", handler); +} + export function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); - // Mount the passport routes. + // Mount the logout handler. router.delete( "/", options.passport.authenticate("jwt", { session: false }), logoutHandler({ redis: app.redis }) ); + // Mount the Local Authentication handlers. router.post( "/local", express.json(), @@ -29,24 +43,10 @@ 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") - ); - router.get( - "/oidc/:oidcID/callback", - wrapAuthn(options.passport, app.signingConfig, "oidc") - ); + // Mount the external auth integrations with middleware/handle wrappers. + wrapPath(app, options, router, "facebook"); + wrapPath(app, options, router, "google"); + wrapPath(app, options, router, "oidc", "/oidc/:oidc"); return router; } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index e29a54134..52cfc74c1 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -37,7 +37,17 @@ export interface FacebookProfile { id: string; } -export type Profile = LocalProfile | OIDCProfile | SSOProfile | FacebookProfile; +export interface GoogleProfile { + type: "google"; + id: string; +} + +export type Profile = + | LocalProfile + | OIDCProfile + | SSOProfile + | FacebookProfile + | GoogleProfile; export interface Token { readonly id: string; diff --git a/src/types/passport-google-oauth2.d.ts b/src/types/passport-google-oauth2.d.ts new file mode 100644 index 000000000..fb0ad8af1 --- /dev/null +++ b/src/types/passport-google-oauth2.d.ts @@ -0,0 +1,38 @@ +declare module "passport-google-oauth2" { + import express from "express"; + import passport from "passport"; + + export interface Profile extends passport.Profile { + id: string; + displayName: string; + } + + export interface AuthenticateOptions extends passport.AuthenticateOptions { + authType?: string; + } + + export interface StrategyOptionWithRequest { + clientID: string; + clientSecret: string; + callbackURL: string; + passReqToCallback: true; + } + + export type VerifyFunctionWithRequest = ( + req: express.Request, + accessToken: string, + refreshToken: string, + profile: Profile, + done: (error: any, user?: any, info?: any) => void + ) => void; + + export class Strategy extends passport.Strategy { + constructor( + options: StrategyOptionWithRequest, + verify: VerifyFunctionWithRequest + ); + + public name: string; + public authenticate(req: express.Request, options?: object): void; + } +}