diff --git a/package-lock.json b/package-lock.json index ea0df219f..22605c63f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2016,13 +2016,12 @@ } }, "@types/mongodb": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.8.tgz", - "integrity": "sha512-5higsHdPx63XKIh5hjr5GGrCCErBqEbpZZiNsUcqk97mMDpCBH9R4dRi/T8bcMrQItCdL+wecagdAj3JPKkuVg==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.14.tgz", + "integrity": "sha512-Hc9nhu9Z33Gq8SP2CZluNlhwbXBCEGAzLMQPEceZG0wUt/ZTzIGaeo8RXu7FWNXfUd6JHh6KHl0YjQlu6TgncQ==", "dev": true, "requires": { "@types/bson": "*", - "@types/events": "*", "@types/node": "*" } }, @@ -2080,6 +2079,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 +18718,22 @@ "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-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 d03269e3d..4a4f4d4c0 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,8 @@ "nodemailer": "^4.6.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", @@ -142,13 +144,14 @@ "@types/lodash": "^4.14.111", "@types/luxon": "^0.5.3", "@types/mini-css-extract-plugin": "^0.2.0", - "@types/mongodb": "^3.1.8", + "@types/mongodb": "^3.1.14", "@types/ms": "^0.7.30", "@types/node": "^10.5.2", "@types/node-fetch": "^2.1.2", "@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/scripts/generateSchemaTypes.js b/scripts/generateSchemaTypes.js index c52393830..11e373843 100644 --- a/scripts/generateSchemaTypes.js +++ b/scripts/generateSchemaTypes.js @@ -37,7 +37,7 @@ async function main() { 'import { Cursor } from "talk-server/models/connection";', 'import TenantContext from "talk-server/graph/tenant/context";', ], - customScalarType: { Cursor: "Cursor", Time: "string" }, + customScalarType: { Cursor: "Cursor", Time: "Date" }, }, }, { @@ -48,6 +48,7 @@ async function main() { importStatements: [ 'import ManagementContext from "talk-server/graph/management/context";', ], + customScalarType: { Time: "Date" }, }, }, ]; diff --git a/src/core/server/app/handlers/api/tenant/auth/local.ts b/src/core/server/app/handlers/api/tenant/auth/local.ts index 181148b87..94f8fc7f9 100644 --- a/src/core/server/app/handlers/api/tenant/auth/local.ts +++ b/src/core/server/app/handlers/api/tenant/auth/local.ts @@ -51,6 +51,11 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( return next(new Error("integration is disabled")); } + if (!tenant.auth.integrations.local.allowRegistration) { + // TODO: replace with better error. + return next(new Error("registration is disabled")); + } + // Get the fields from the body. Validate will throw an error if the body // does not conform to the specification. const { username, password, email }: SignupBody = validate( diff --git a/src/core/server/app/middleware/context/tenant.ts b/src/core/server/app/middleware/context/tenant.ts index c52772b6b..549e70266 100644 --- a/src/core/server/app/middleware/context/tenant.ts +++ b/src/core/server/app/middleware/context/tenant.ts @@ -2,6 +2,7 @@ import { RequestHandler } from "express-jwt"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { Config } from "talk-common/config"; import TenantContext from "talk-server/graph/tenant/context"; import { TaskQueue } from "talk-server/services/queue"; import { Request } from "talk-server/types/express"; @@ -10,12 +11,14 @@ export interface TenantContextMiddlewareOptions { mongo: Db; redis: Redis; queue: TaskQueue; + config: Config; } export const tenantContext = ({ mongo, redis, queue, + config, }: TenantContextMiddlewareOptions): RequestHandler => ( req: Request, res, @@ -38,6 +41,7 @@ export const tenantContext = ({ req.talk.context = { tenant: new TenantContext({ req, + config, mongo, redis, tenant, diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 207fb1e92..85cf5c8fa 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -6,6 +6,8 @@ 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 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"; @@ -50,6 +52,12 @@ export function createPassport( // Use the SSOStrategy. auth.use(new JWTStrategy(options)); + // 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 new file mode 100644 index 000000000..54859d0b6 --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -0,0 +1,105 @@ +import { Db } from "mongodb"; +import { Profile, Strategy } from "passport-facebook"; + +import { Config } from "talk-common/config"; +import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2"; +import { constructTenantURL } from "talk-server/app/url"; +import { + GQLAuthIntegrations, + 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 { upsert } from "talk-server/services/users"; + +export interface FacebookStrategyOptions { + config: Config; + mongo: Db; + tenantCache: TenantCache; +} + +export default class FacebookStrategy extends OAuth2Strategy< + GQLFacebookAuthIntegration, + Strategy +> { + public name = "facebook"; + + protected getIntegration = (integrations: GQLAuthIntegrations) => + integrations.facebook; + + 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, + }; + + 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: constructTenantURL( + 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/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts new file mode 100644 index 000000000..88fb4d7f7 --- /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 { constructTenantURL } 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: constructTenantURL( + this.config, + tenant, + "/api/tenant/auth/google/callback" + ), + passReqToCallback: true, + }, + this.verifyCallback + ); + } +} diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index 273bce5d3..59e27f227 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -75,6 +75,13 @@ export class JWTStrategy extends Strategy { throw new Error("token could not be decoded"); } + // TODO: add OIDC support. + // At the moment, OpenID Connect tokens are not supported here directly, + // instead, the default implementation redirects the user to the + // authorization endpoint where they login, and a redirection occurs + // yielding the token to us via the Authorization Code Flow. We then issue a + // Talk Token for that request, that the client uses after. + // Handle SSO integrations. if (this.verifiers.sso.supports(token, tenant)) { return this.verifiers.sso.verify(tokenString, token, tenant); 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..dbcdcfe89 --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -0,0 +1,128 @@ +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; + scope?: string[]; +} + +export default abstract class OAuth2Strategy< + T extends OAuth2Integration, + U extends Strategy +> extends Strategy { + protected config: Config; + protected mongo: Db; + protected cache: TenantCacheAdapter; + private scope?: string[]; + + 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; + + 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, + scope: this.scope, + }); + } catch (err) { + return this.error(err); + } + } +} diff --git a/src/core/server/app/middleware/passport/strategies/oidc/discover.ts b/src/core/server/app/middleware/passport/strategies/oidc/discover.ts new file mode 100644 index 000000000..3fe4679a6 --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/oidc/discover.ts @@ -0,0 +1,62 @@ +import fetch from "node-fetch"; +import { URL } from "url"; + +/** + * Configuration that Talk is expecting. + */ +export interface DiscoveryConfiguration { + issuer: string; + authorizationURL: string; + tokenURL?: string; + jwksURI: string; +} + +/** + * Subset of configuration as defined in the set of Provider Metadata: + * + * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ +interface DiscoveryRawConfiguration { + issuer: string; + authorization_endpoint: string; + token_endpoint?: string; + jwks_uri: string; +} + +/** + * discover will discover the configuration for the issuer. + * + * @param issuer the Issuer URL that should be used to determine the + * configuration + */ +export async function discover( + issuer: URL +): Promise { + // Any provider MUST provide a .well-known url that is JSON parsable based + // on the issuer: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + const configurationURL = + issuer.origin + + issuer.pathname.replace(/\/$/, "") + + "/.well-known/openid-configuration"; + const res = await fetch(configurationURL); + + // Ensure that it responds correctly. + if (res.status !== 200) { + return null; + } + + try { + // Parse the configuration + const meta: DiscoveryRawConfiguration = await res.json(); + + return { + issuer: meta.issuer, + authorizationURL: meta.authorization_endpoint, + tokenURL: meta.token_endpoint, + jwksURI: meta.jwks_uri, + }; + } catch (err) { + // TODO: log the error + return null; + } +} diff --git a/src/core/server/app/middleware/passport/strategies/oidc.ts b/src/core/server/app/middleware/passport/strategies/oidc/index.ts similarity index 79% rename from src/core/server/app/middleware/passport/strategies/oidc.ts rename to src/core/server/app/middleware/passport/strategies/oidc/index.ts index b810a18f9..1d792f5f1 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/index.ts @@ -32,23 +32,26 @@ export interface OIDCIDToken { iss: string; sub: string; exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for - email?: string; + email: string; email_verified?: boolean; picture?: string; name?: string; nickname?: string; } -export interface StrategyItem { - strategy: OAuth2Strategy; - jwksClient?: JwksClient; -} +export type StrategyItem = Record< + string, + { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; + } +>; export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { if ( (token as OIDCIDToken).iss && (token as OIDCIDToken).sub && - (token as OIDCIDToken).email + (token as OIDCIDToken).aud ) { return true; } @@ -85,9 +88,18 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => ( }; function getEnabledIntegration( - tenant: Tenant + tenant: Tenant, + oidcID: string ): Required { - const integration = tenant.auth.integrations.oidc; + if (!tenant.auth.integrations.oidc) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Grab the OIDC Integration from the list of integrations. + const integration = tenant.auth.integrations.oidc.find( + ({ id }) => id === oidcID + ); if (!integration) { // TODO: return a better error. throw new Error("integration not found"); @@ -124,17 +136,15 @@ export const OIDCIDTokenSchema = Joi.object() email: Joi.string(), email_verified: Joi.boolean().default(false), picture: Joi.string().default(undefined), + name: Joi.string().default(undefined), + nickname: Joi.string().default(undefined), }) - .optionalKeys(["picture", "email_verified"]); - -export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({ - name: Joi.string().default(undefined), - nickname: Joi.string().default(undefined), -}).optionalKeys(["name", "nickname"]); + .optionalKeys(["picture", "email_verified", "name", "nickname"]); export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, + integration: GQLOIDCAuthIntegration, token: OIDCIDToken ) { // Unpack/validate the token content. @@ -147,12 +157,7 @@ export async function findOrCreateOIDCUser( picture, name, nickname, - }: OIDCIDToken = validate( - tenant.auth.integrations.oidc!.displayNameEnable - ? OIDCDisplayNameIDTokenSchema - : OIDCIDTokenSchema, - token - ); + }: OIDCIDToken = validate(OIDCIDTokenSchema, token); // Construct the profile that will be used to query for the user. const profile: OIDCProfile = { @@ -165,10 +170,13 @@ export async function findOrCreateOIDCUser( // Try to lookup user given their id provided in the `sub` claim. let user = await retrieveUserWithProfile(db, tenant.id, profile); if (!user) { + if (!integration.allowRegistration) { + // Registration is disabled, so we can't create the user user here. + return; + } + // FIXME: implement rules. - // Default the displayName. When it is disabled, Joi will strip the - // displayName fields from the token, so it will fallback to undefined. const displayName = nickname || name || undefined; // Create the new user, as one didn't exist before! @@ -209,43 +217,51 @@ export default class OIDCStrategy extends Strategy { this.mongo = mongo; this.cache = new TenantCacheAdapter(tenantCache); - - // Connect the cache adapter. - this.cache.subscribe(); } private lookupJWKSClient( req: Request, tenantID: string, oidc: Required - ) { - let entry = this.cache.get(tenantID); - if (!entry) { + ): jwks.JwksClient { + let tenantIntegrations = this.cache.get(tenantID); + if (!tenantIntegrations || !tenantIntegrations[oidc.id]) { const strategy = this.createStrategy(req, oidc); // Create the entry. - entry = { - strategy, + tenantIntegrations = { + [oidc.id]: { + strategy, + }, + ...(tenantIntegrations || {}), }; // We don't reset the entry in the cache here because if we just created // it, we'll be creating the jwksClient anyways, so we'll update it there. } - if (!entry.jwksClient) { + const tenantIntegration = tenantIntegrations[oidc.id]; + + if (!tenantIntegration.jwksClient) { // Create the new JWKS client. const jwksClient = jwks({ jwksUri: oidc.jwksURI, }); // Set the jwksClient on the entry. - entry.jwksClient = jwksClient; + tenantIntegration.jwksClient = jwksClient; // Update the cached entry. - this.cache.set(tenantID, entry); + this.cache.set(tenantID, { + [oidc.id]: { + ...tenantIntegration, + jwksClient, + }, + ...tenantIntegrations, + }); } - return entry.jwksClient; + return tenantIntegration.jwksClient; } private userAuthenticatedCallback = ( @@ -271,11 +287,14 @@ export default class OIDCStrategy extends Strategy { return done(new Error("tenant not found")); } + // Grab the OIDC ID from the request. + const { oidcID }: { oidcID: string } = req.params; + // Get the integration from the tenant. If needed, it will be used to create // a new strategy. let integration: Required; try { - integration = getEnabledIntegration(tenant); + integration = getEnabledIntegration(tenant, oidcID); } catch (err) { // TODO: wrap error? return done(err); @@ -301,6 +320,7 @@ export default class OIDCStrategy extends Strategy { const user = await findOrCreateOIDCUser( this.mongo, tenant, + integration, decoded as OIDCIDToken ); return done(null, user); @@ -318,7 +338,10 @@ export default class OIDCStrategy extends Strategy { const { clientID, clientSecret, authorizationURL, tokenURL } = integration; // Construct the callbackURL from the request. - const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback"); + const callbackURL = reconstructURL( + req, + `/api/tenant/auth/oidc/${integration.id}/callback` + ); // Create a new OAuth2Strategy, where we pass the verify callback bound to // this OIDCStrategy instance. @@ -335,39 +358,45 @@ export default class OIDCStrategy extends Strategy { ); } - private async lookupStrategy(req: Request) { + private lookupStrategy(req: Request): OAuth2Strategy { const { tenant } = req.talk!; if (!tenant) { // TODO: return a better error. throw new Error("tenant not found"); } + // Get the OIDC ID. + const { oidcID }: { oidcID: string } = req.params; + // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - const integration = getEnabledIntegration(tenant); + const integration = getEnabledIntegration(tenant, oidcID); // Try to get the Tenant's cached integrations. - let entry = this.cache.get(tenant.id); - if (!entry) { + let tenantIntegrations = this.cache.get(tenant.id); + if (!tenantIntegrations || !tenantIntegrations[oidcID]) { // Create the strategy. const strategy = this.createStrategy(req, integration); // Reset the entry. - entry = { - strategy, + tenantIntegrations = { + [oidcID]: { + strategy, + }, + ...(tenantIntegrations || {}), }; // Update the cached integrations value. - this.cache.set(tenant.id, entry); + this.cache.set(tenant.id, tenantIntegrations); } - return entry.strategy; + return tenantIntegrations[oidcID].strategy; } - public async authenticate(req: Request) { + public authenticate(req: Request) { try { // Lookup the strategy. - const strategy = await this.lookupStrategy(req); + const strategy = this.lookupStrategy(req); if (!strategy) { throw new Error("strategy not found"); } diff --git a/src/core/server/app/middleware/passport/strategies/oidc.spec.ts b/src/core/server/app/middleware/passport/strategies/oidc/oidc.spec.ts similarity index 78% rename from src/core/server/app/middleware/passport/strategies/oidc.spec.ts rename to src/core/server/app/middleware/passport/strategies/oidc/oidc.spec.ts index 141b145ba..b8afbffad 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/oidc.spec.ts @@ -1,7 +1,4 @@ -import { - OIDCDisplayNameIDTokenSchema, - OIDCIDTokenSchema, -} from "talk-server/app/middleware/passport/strategies/oidc"; +import { OIDCIDTokenSchema } from "talk-server/app/middleware/passport/strategies/oidc"; import { validate } from "talk-server/app/request/body"; describe("OIDCIDTokenSchema", () => { @@ -42,9 +39,7 @@ describe("OIDCIDTokenSchema", () => { expect(validate(OIDCIDTokenSchema, token)).toEqual(token); }); -}); -describe("OIDCDisplayNameIDTokenSchema", () => { it("allows a valid payload", () => { const token = { sub: "sub", @@ -56,7 +51,7 @@ describe("OIDCDisplayNameIDTokenSchema", () => { nickname: "nickname", }; - expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); }); it("allows an empty name", () => { @@ -69,7 +64,7 @@ describe("OIDCDisplayNameIDTokenSchema", () => { nickname: "nickname", }; - expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); }); it("allows an empty nickname", () => { @@ -82,6 +77,6 @@ describe("OIDCDisplayNameIDTokenSchema", () => { name: "name", }; - expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); }); }); diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts index 7f9816541..049fe45e9 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts @@ -1,6 +1,5 @@ import { isSSOToken, - SSODisplayNameUserProfileSchema, SSOUserProfileSchema, } from "talk-server/app/middleware/passport/strategies/verifiers/sso"; import { validate } from "talk-server/app/request/body"; @@ -44,9 +43,7 @@ describe("SSOUserProfileSchema", () => { expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); }); -}); -describe("SSODisplayNameUserProfileSchema", () => { it("allows a valid payload", () => { const profile = { id: "id", @@ -56,7 +53,7 @@ describe("SSODisplayNameUserProfileSchema", () => { displayName: "displayName", }; - expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); }); it("allows an empty avatar", () => { @@ -67,7 +64,7 @@ describe("SSODisplayNameUserProfileSchema", () => { displayName: "displayName", }; - expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); }); it("allows an empty displayName", () => { @@ -78,6 +75,6 @@ describe("SSODisplayNameUserProfileSchema", () => { avatar: "avatar", }; - expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); }); }); diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index 1ff9ff44f..e48fdd1b7 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -3,7 +3,10 @@ import jwt from "jsonwebtoken"; import { Db } from "mongodb"; import { validate } from "talk-server/app/request/body"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLSSOAuthIntegration, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user"; import { upsert } from "talk-server/services/users"; @@ -30,16 +33,14 @@ export const SSOUserProfileSchema = Joi.object() email: Joi.string(), username: Joi.string(), avatar: Joi.string().default(undefined), + displayName: Joi.string().default(undefined), }) - .optionalKeys(["avatar"]); - -export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ - displayName: Joi.string().default(undefined), -}).optionalKeys(["displayName"]); + .optionalKeys(["avatar", "displayName"]); export async function findOrCreateSSOUser( db: Db, tenant: Tenant, + integration: GQLSSOAuthIntegration, token: SSOToken ) { if (!token.user) { @@ -49,9 +50,7 @@ export async function findOrCreateSSOUser( // Unpack/validate the token content. const { id, email, username, displayName, avatar }: SSOUserProfile = validate( - tenant.auth.integrations.sso!.displayNameEnable - ? SSODisplayNameUserProfileSchema - : SSOUserProfileSchema, + SSOUserProfileSchema, token.user ); @@ -63,6 +62,11 @@ export async function findOrCreateSSOUser( // Try to lookup user given their id provided in the `sub` claim. let user = await retrieveUserWithProfile(db, tenant.id, profile); if (!user) { + if (!integration.allowRegistration) { + // Registration is disabled, so we can't create the user user here. + return null; + } + // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. // Create the new user, as one didn't exist before! @@ -138,6 +142,6 @@ export class SSOVerifier { algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. }); - return findOrCreateSSOUser(this.mongo, tenant, token); + return findOrCreateSSOUser(this.mongo, tenant, integration, token); } } diff --git a/src/core/server/app/router/api/auth.ts b/src/core/server/app/router/api/auth.ts index df7f7a05b..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,11 +43,10 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) { signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); - router.get( - "/oidc/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/app/router/api/tenant.ts b/src/core/server/app/router/api/tenant.ts index 23567d770..2f7e73d4f 100644 --- a/src/core/server/app/router/api/tenant.ts +++ b/src/core/server/app/router/api/tenant.ts @@ -42,11 +42,7 @@ export async function createTenantRouter( // Any users may submit their GraphQL requests with authentication, this // middleware will unpack their user into the request. options.passport.authenticate("jwt", { session: false }), - tenantContext({ - mongo: app.mongo, - redis: app.redis, - queue: app.queue, - }), + tenantContext(app), await tenantGraphMiddleware({ schema: app.schemas.tenant, config: app.config, diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts index 3654c4c46..de4686e24 100644 --- a/src/core/server/app/url.ts +++ b/src/core/server/app/url.ts @@ -1,3 +1,5 @@ +import { Config } from "talk-common/config"; +import { Tenant } from "talk-server/models/tenant"; import { Request } from "talk-server/types/express"; import { URL } from "url"; @@ -11,6 +13,22 @@ export function reconstructURL(req: Request, path: string = "/"): string { return url.href; } +/** + * constructTenantURL will construct a URL based off of the Tenant's domain. + */ +export function constructTenantURL( + config: Config, + tenant: Pick, + path: string = "/" +): string { + let url: URL = new URL(path, `https://${tenant.domain}`); + if (config.get("env") === "development") { + url = new URL(path, `http://${tenant.domain}:${config.get("port")}`); + } + + return url.href; +} + export function doesRequireSchemePrefixing(url: string) { return !url.startsWith("http"); } diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts index 7e191e3f8..016aa37d6 100644 --- a/src/core/server/graph/common/context.ts +++ b/src/core/server/graph/common/context.ts @@ -1,17 +1,21 @@ +import { Config } from "talk-common/config"; import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; export interface CommonContextOptions { user?: User; req?: Request; + config: Config; } export default class CommonContext { public user?: User; public req?: Request; + public config: Config; - constructor({ user, req }: CommonContextOptions) { + constructor({ user, req, config }: CommonContextOptions) { this.user = user; this.req = req; + this.config = config; } } diff --git a/src/core/server/graph/common/scalars/time.ts b/src/core/server/graph/common/scalars/time.ts new file mode 100644 index 000000000..66e5c9fc0 --- /dev/null +++ b/src/core/server/graph/common/scalars/time.ts @@ -0,0 +1,36 @@ +import { GraphQLScalarType } from "graphql"; +import { Kind } from "graphql/language"; +import { DateTime } from "luxon"; + +export default new GraphQLScalarType({ + name: "Time", + description: "Time represented as an ISO8601 string.", + serialize(value) { + if (typeof value === "string") { + return value; + } + + return value.toISOString(); + }, + parseValue(value) { + return new Date(value); + }, + parseLiteral(ast) { + switch (ast.kind) { + case Kind.STRING: + // This handles an empty string. + if (ast.value && ast.value.length === 0) { + return null; + } + + const date = DateTime.fromISO(ast.value, {}); + if (!date.isValid) { + return null; + } + + return date.toJSDate(); + default: + return null; + } + }, +}); diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index cdb25dbe3..41e59e094 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,18 +1,20 @@ import { Db } from "mongodb"; +import { Config } from "talk-common/config"; import CommonContext from "talk-server/graph/common/context"; import { Request } from "talk-server/types/express"; export interface ManagementContextOptions { mongo: Db; + config: Config; req?: Request; } export default class ManagementContext extends CommonContext { public mongo: Db; - constructor({ req, mongo }: ManagementContextOptions) { - super({ req }); + constructor({ req, mongo, config }: ManagementContextOptions) { + super({ req, config }); this.mongo = mongo; } diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts index 6dd15e5da..5e96a6a36 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -10,5 +10,5 @@ import ManagementContext from "./context"; export default (schema: GraphQLSchema, config: Config, mongo: Db) => graphqlMiddleware(config, async (req: Request) => ({ schema, - context: new ManagementContext({ req, mongo }), + context: new ManagementContext({ req, mongo, config }), })); diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts index b1bcbcef8..7cb79b325 100644 --- a/src/core/server/graph/management/resolvers/index.ts +++ b/src/core/server/graph/management/resolvers/index.ts @@ -1,5 +1,9 @@ +import Time from "talk-server/graph/common/scalars/time"; + import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types"; -const Resolvers: GQLResolver = {}; +const Resolvers: GQLResolver = { + Time, +}; export default Resolvers; diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index b6facbb30..3456e8029 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -8,6 +8,7 @@ import { TaskQueue } from "talk-server/services/queue"; import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; +import { Config } from "talk-common/config"; import loaders from "./loaders"; import mutators from "./mutators"; @@ -17,6 +18,7 @@ export interface TenantContextOptions { tenant: Tenant; tenantCache: TenantCache; queue: TaskQueue; + config: Config; req?: Request; user?: User; } @@ -37,10 +39,11 @@ export default class TenantContext extends CommonContext { tenant, mongo, redis, + config, tenantCache, queue, }: TenantContextOptions) { - super({ user, req }); + super({ user, req, config }); this.tenant = tenant; this.tenantCache = tenantCache; diff --git a/src/core/server/graph/tenant/loaders/auth.ts b/src/core/server/graph/tenant/loaders/auth.ts new file mode 100644 index 000000000..9b13a9cda --- /dev/null +++ b/src/core/server/graph/tenant/loaders/auth.ts @@ -0,0 +1,14 @@ +import DataLoader from "dataloader"; + +import TenantContext from "talk-server/graph/tenant/context"; +import { GQLDiscoveredOIDCConfiguration } from "talk-server/graph/tenant/schema/__generated__/types"; +import { discoverOIDCConfiguration } from "talk-server/services/tenant"; + +export default (ctx: TenantContext) => ({ + discoverOIDCConfiguration: new DataLoader< + string, + GQLDiscoveredOIDCConfiguration | null + >(issuers => + Promise.all(issuers.map(issuer => discoverOIDCConfiguration(issuer))) + ), +}); diff --git a/src/core/server/graph/tenant/loaders/index.ts b/src/core/server/graph/tenant/loaders/index.ts index 2c00687b3..671edf856 100644 --- a/src/core/server/graph/tenant/loaders/index.ts +++ b/src/core/server/graph/tenant/loaders/index.ts @@ -1,9 +1,12 @@ import Context from "talk-server/graph/tenant/context"; + +import Auth from "./auth"; import Comments from "./comments"; import Stories from "./stories"; import Users from "./users"; export default (ctx: Context) => ({ + Auth: Auth(ctx), Stories: Stories(ctx), Comments: Comments(ctx), Users: Users(ctx), diff --git a/src/core/server/graph/tenant/mutators/settings.ts b/src/core/server/graph/tenant/mutators/settings.ts index 19a7c04e0..16c3ac6c0 100644 --- a/src/core/server/graph/tenant/mutators/settings.ts +++ b/src/core/server/graph/tenant/mutators/settings.ts @@ -1,11 +1,43 @@ import { isNull, omitBy } from "lodash"; import TenantContext from "talk-server/graph/tenant/context"; -import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLCreateOIDCAuthIntegrationInput, + GQLDeleteOIDCAuthIntegrationInput, + GQLSettingsInput, + GQLUpdateOIDCAuthIntegrationInput, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; -import { update } from "talk-server/services/tenant"; +import { + createOIDCAuthIntegration, + deleteOIDCAuthIntegration, + regenerateSSOKey, + update, + updateOIDCAuthIntegration, +} from "talk-server/services/tenant"; export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({ update: (input: GQLSettingsInput): Promise => update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)), + regenerateSSOKey: (): Promise => + regenerateSSOKey(mongo, redis, tenantCache, tenant), + createOIDCAuthIntegration: (input: GQLCreateOIDCAuthIntegrationInput) => + createOIDCAuthIntegration( + mongo, + redis, + tenantCache, + tenant, + input.configuration + ), + updateOIDCAuthIntegration: (input: GQLUpdateOIDCAuthIntegrationInput) => + updateOIDCAuthIntegration( + mongo, + redis, + tenantCache, + tenant, + input.id, + input.configuration + ), + deleteOIDCAuthIntegration: (input: GQLDeleteOIDCAuthIntegrationInput) => + deleteOIDCAuthIntegration(mongo, redis, tenantCache, tenant, input.id), }); diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index f4a0417a2..e94466653 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -1,10 +1,13 @@ import Cursor from "talk-server/graph/common/scalars/cursor"; +import Time from "talk-server/graph/common/scalars/time"; + import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import AuthIntegrations from "./auth_integrations"; import Comment from "./comment"; import CommentCounts from "./comment_counts"; import Mutation from "./mutation"; +import OIDCAuthIntegration from "./oidc_auth_integration"; import Profile from "./profile"; import Query from "./query"; import Story from "./story"; @@ -16,8 +19,10 @@ const Resolvers: GQLResolver = { CommentCounts, Cursor, Mutation, + OIDCAuthIntegration, Profile, Query, + Time, Story, User, }; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 77f0ca109..32425a67d 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -47,6 +47,46 @@ const Mutation: GQLMutationTypeResolver = { comment: await ctx.mutators.Comment.deleteFlag(input), clientMutationId: input.clientMutationId, }), + regenerateSSOKey: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.regenerateSSOKey(), + clientMutationId: input.clientMutationId, + }), + createOIDCAuthIntegration: async (source, { input }, ctx) => { + const result = await ctx.mutators.Settings.createOIDCAuthIntegration(input); + if (!result) { + return { clientMutationId: input.clientMutationId }; + } + + return { + integration: result.integration, + settings: result.tenant, + clientMutationId: input.clientMutationId, + }; + }, + updateOIDCAuthIntegration: async (source, { input }, ctx) => { + const result = await ctx.mutators.Settings.updateOIDCAuthIntegration(input); + if (!result) { + return { clientMutationId: input.clientMutationId }; + } + + return { + integration: result.integration, + settings: result.tenant, + clientMutationId: input.clientMutationId, + }; + }, + deleteOIDCAuthIntegration: async (source, { input }, ctx) => { + const result = await ctx.mutators.Settings.deleteOIDCAuthIntegration(input); + if (!result) { + return { clientMutationId: input.clientMutationId }; + } + + return { + integration: result.integration, + settings: result.tenant, + clientMutationId: input.clientMutationId, + }; + }, }; export default Mutation; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts new file mode 100644 index 000000000..3bd4aab70 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -0,0 +1,26 @@ +import { constructTenantURL, reconstructURL } from "talk-server/app/url"; +import { + GQLOIDCAuthIntegration, + GQLOIDCAuthIntegrationTypeResolver, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< + GQLOIDCAuthIntegration +> = { + callbackURL: (integration, args, ctx) => { + const path = `/api/tenant/auth/oidc/${integration.id}`; + + // If the request is available, then prefer it over building from the tenant + // as the tenant does not include the port number. This should only really + // be a problem if the graph API is called internally. + if (ctx.req) { + return reconstructURL(ctx.req, path); + } + + // Note that when constructing the callback url with the tenant, the port + // information is lost. + return constructTenantURL(ctx.config, ctx.tenant, path); + }, +}; + +export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index 6fba12b65..732e4db6f 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -6,6 +6,8 @@ const Query: GQLQueryTypeResolver = { id ? ctx.loaders.Comments.comment.load(id) : null, settings: (source, args, ctx) => ctx.tenant, me: (source, args, ctx) => ctx.user, + discoverOIDCConfiguration: (source, { issuer }, ctx) => + ctx.loaders.Auth.discoverOIDCConfiguration.load(issuer), }; export default Query; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index fe3fefa8b..c63c887d7 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -226,9 +226,9 @@ enum MODERATION_MODE { } """ -Wordlist describes all the available wordlists. +WordList describes all the available wordLists. """ -type Wordlist { +type WordList { """ banned words will by default reject the comment if it is found. """ @@ -244,12 +244,38 @@ type Wordlist { ## Auth ################################################################################ +########################## +## AuthenticationTargetFilter +########################## + +""" +AuthenticationTargetFilter when non-null, will specify the targets that a +specific authentication integration will be enabled on. +""" +type AuthenticationTargetFilter { + admin: Boolean! + stream: Boolean! +} + ########################## ## LocalAuthIntegration ########################## type LocalAuthIntegration { enabled: Boolean! + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter } ########################## @@ -264,42 +290,158 @@ embed to allow single sign on. type SSOAuthIntegration { enabled: Boolean! + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + """ key is the secret that is used to sign tokens. """ key: String @auth(roles: [ADMIN]) """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. + keyGeneratedAt is the Time that the key was effective from. """ - displayNameEnable: Boolean @auth(roles: [ADMIN]) + keyGeneratedAt: Time @auth(roles: [ADMIN]) } ########################## ## OIDCAuthIntegration ########################## +""" +DiscoveredOIDCConfiguration contains the discovered Provider Metadata as defined +in: + +https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + +Discovery is not supported on all providers, and is described in the OpenID +Connect Discovery 1.0 incorporating errata set 1: + +https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig +""" +type DiscoveredOIDCConfiguration { + """ + issuer is defined as the `issuer` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + issuer: String! + + """ + authorizationURL is defined as the `authorization_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + authorizationURL: String! + + """ + tokenURL is defined as the `token_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + tokenURL: String + + """ + jwksURI is defined as the `jwks_uri` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + jwksURI: String! +} + """ OIDCAuthIntegration provides a way to store Open ID Connect credentials. This will be used in the admin to provide staff logins for users. """ type OIDCAuthIntegration { + """ + id is the identifier of the OIDCAuthIntegration. + """ + id: ID! + + """ + enabled, when true, allows the integration to be enabled. + """ enabled: Boolean! - name: String - clientID: String @auth(roles: [ADMIN]) - clientSecret: String @auth(roles: [ADMIN]) - authorizationURL: String @auth(roles: [ADMIN]) - tokenURL: String @auth(roles: [ADMIN]) - jwksURI: String @auth(roles: [ADMIN]) - issuer: String @auth(roles: [ADMIN]) + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean! """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. """ - displayNameEnable: Boolean @auth(roles: [ADMIN]) + targetFilter: AuthenticationTargetFilter + + """ + name is the label assigned to reference the provider of the OIDC integration, + and will be used in situations where the name of the provider needs to be + displayed, like the login button. + """ + name: String + + """ + callbackURL is the URL that the user should be redirected to in order to start + an authentication flow with the given integration. This field is not stored, + and is instead computed from the Tenant. + """ + callbackURL: String! + + """ + clientID is the Client Identifier as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.2 + """ + clientID: String! @auth(roles: [ADMIN]) + + """ + clientSecret is the Client Secret as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.3.1 + """ + clientSecret: String! @auth(roles: [ADMIN]) + + """ + authorizationURL is defined as the `authorization_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + authorizationURL: String! @auth(roles: [ADMIN]) + + """ + tokenURL is defined as the `token_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + tokenURL: String! @auth(roles: [ADMIN]) + + """ + jwksURI is defined as the `jwks_uri` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + jwksURI: String! @auth(roles: [ADMIN]) + + """ + issuer is defined as the `issuer` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + issuer: String! @auth(roles: [ADMIN]) } ########################## @@ -308,6 +450,20 @@ type OIDCAuthIntegration { type GoogleAuthIntegration { enabled: Boolean! + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + clientID: String @auth(roles: [ADMIN]) clientSecret: String @auth(roles: [ADMIN]) } @@ -318,18 +474,47 @@ type GoogleAuthIntegration { type FacebookAuthIntegration { enabled: Boolean! + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + clientID: String @auth(roles: [ADMIN]) clientSecret: String @auth(roles: [ADMIN]) } +########################## +## Auth +########################## + type AuthIntegrations { local: LocalAuthIntegration! sso: SSOAuthIntegration! - oidc: OIDCAuthIntegration! + oidc: [OIDCAuthIntegration!]! google: GoogleAuthIntegration! facebook: FacebookAuthIntegration! } +""" +AuthDisplayNameConfiguration allows configuration related to Display Names. +""" +type AuthDisplayNameConfiguration { + """ + enabled when true will allow the display name to be used by other + AuthIntegrations. + """ + enabled: Boolean! +} + """ Auth contains all the settings related to authentication and authorization. @@ -340,6 +525,12 @@ type Auth { authentication solutions. """ integrations: AuthIntegrations! + + """ + displayName contains configuration related to the use of Display Names across + AuthIntegrations. + """ + displayName: AuthDisplayNameConfiguration @auth(roles: [ADMIN]) } ################################################################################ @@ -646,9 +837,9 @@ type Settings { email: Email! @auth(roles: [ADMIN]) """ - wordlist will return a given list of words. + wordList will return a given list of words. """ - wordlist: Wordlist! @auth(roles: [ADMIN, MODERATOR]) + wordList: WordList! @auth(roles: [ADMIN, MODERATOR]) """ auth contains all the settings related to authentication and authorization. @@ -1126,7 +1317,8 @@ type Query { story(id: ID, url: String): Story """ - me is the current logged in User. + me is the current logged in User. If no user is currently logged in, it will + return null. """ me: User @@ -1134,6 +1326,18 @@ type Query { settings is the Settings for a given Tenant. """ settings: Settings! + + """ + discoverOIDCConfiguration will discover the OpenID Connect configuration based + on the provided issuer. Discovery is not supported on all providers, and is + described in the OpenID Connect Discovery 1.0 incorporating errata set 1: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + + If the provider does not support discovery, the response will be null. + """ + discoverOIDCConfiguration(issuer: String!): DiscoveredOIDCConfiguration + @auth(roles: [ADMIN]) } ################################################################################ @@ -1246,7 +1450,7 @@ input SettingsEmailInput { fromAddress: String } -input SettingsWordlistInput { +input SettingsWordListInput { """ banned words will by default reject the comment if it is found. """ @@ -1258,49 +1462,81 @@ input SettingsWordlistInput { suspect: [String!] } +input SettingsAuthenticationTargetFilterInput { + admin: Boolean! + stream: Boolean! +} + input SettingsLocalAuthIntegrationInput { enabled: Boolean + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput } input SettingsSSOAuthIntegrationInput { enabled: Boolean """ - key is the secret that is used to sign tokens. + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. """ - key: String + allowRegistration: Boolean """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. """ - displayNameEnable: Boolean -} - -input SettingsOIDCAuthIntegrationInput { - enabled: Boolean - - name: String - clientID: String - clientSecret: String - authorizationURL: String - tokenURL: String - - """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. - """ - displayNameEnable: Boolean + targetFilter: SettingsAuthenticationTargetFilterInput } input SettingsGoogleAuthIntegrationInput { enabled: Boolean + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + clientID: String clientSecret: String } input SettingsFacebookAuthIntegrationInput { enabled: Boolean + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + clientID: String clientSecret: String } @@ -1308,7 +1544,6 @@ input SettingsFacebookAuthIntegrationInput { input SettingsAuthIntegrationsInput { local: SettingsLocalAuthIntegrationInput sso: SettingsSSOAuthIntegrationInput - oidc: SettingsOIDCAuthIntegrationInput google: SettingsGoogleAuthIntegrationInput facebook: SettingsFacebookAuthIntegrationInput } @@ -1541,9 +1776,9 @@ input SettingsInput { organizationContactEmail: String """ - wordlist will return a given list of words. + wordList will return a given list of words. """ - wordlist: SettingsWordlistInput + wordList: SettingsWordListInput """ email is the set of credentials and settings associated with the organization. @@ -1777,6 +2012,255 @@ type DeleteCommentFlagPayload { clientMutationId: String! } +################## +## regenerateSSOKey +################## + +input RegenerateSSOKeyInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type RegenerateSSOKeyPayload { + """ + settings is the Settings that the SSO key was regenerated on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# createOIDCAuthIntegration +################## + +input CreateOIDCAuthIntegrationConfigurationInput { + """ + name is the label assigned to reference the provider of the OIDC integration, + and will be used in situations where the name of the provider needs to be + displayed, like the login button. + """ + name: String! + + """ + clientID is the Client Identifier as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.2 + """ + clientID: String! + + """ + clientSecret is the Client Secret as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.3.1 + """ + clientSecret: String! + + """ + authorizationURL is defined as the `authorization_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + authorizationURL: String! + + """ + tokenURL is defined as the `token_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + tokenURL: String! + + """ + jwksURI is defined as the `jwks_uri` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + jwksURI: String! + + """ + issuer is defined as the `issuer` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + issuer: String! +} + +input CreateOIDCAuthIntegrationInput { + """ + configuration contains the configuration to be used to create the auth integration. + """ + configuration: CreateOIDCAuthIntegrationConfigurationInput! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateOIDCAuthIntegrationPayload { + """ + integration is the OIDCAuthIntegration we just created. + """ + integration: OIDCAuthIntegration + + """ + settings is the Settings that the OIDCAuthIntegration was created on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateOIDCAuthIntegration +################## + +input UpdateOIDCAuthIntegrationConfigurationInput { + """ + enabled, when true, allows the integration to be enabled. + """ + enabled: Boolean + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + + """ + name is the label assigned to reference the provider of the OIDC integration, + and will be used in situations where the name of the provider needs to be + displayed, like the login button. + """ + name: String + + """ + clientID is the Client Identifier as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.2 + """ + clientID: String + + """ + clientSecret is the Client Secret as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.3.1 + """ + clientSecret: String + + """ + authorizationURL is defined as the `authorization_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + authorizationURL: String + + """ + tokenURL is defined as the `token_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + tokenURL: String + + """ + jwksURI is defined as the `jwks_uri` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + jwksURI: String + + """ + issuer is defined as the `issuer` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + issuer: String +} + +input UpdateOIDCAuthIntegrationInput { + """ + id is the ID of the specific OpenID Connect integration that we are updating. + """ + id: ID! + + """ + configuration contains the configuration to be used to update the OpenID + Connect integration. + """ + configuration: UpdateOIDCAuthIntegrationConfigurationInput! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateOIDCAuthIntegrationPayload { + """ + integration is the OIDCAuthIntegration we just updated. + """ + integration: OIDCAuthIntegration + + """ + settings is the Settings that the OIDCAuthIntegration was updated on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# deleteOIDCAuthIntegration +################## + +input DeleteOIDCAuthIntegrationInput { + """ + id is the ID of the specific OpenID Connect integration that we are deleting. + """ + id: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeleteOIDCAuthIntegrationPayload { + """ + integration is the OIDCAuthIntegration we just deleted. + """ + integration: OIDCAuthIntegration + + """ + settings is the Settings that the OIDCAuthIntegration was deleted on with the + OIDCAuthIntegration removed from it. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -1799,6 +2283,35 @@ type Mutation { updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload @auth(roles: [ADMIN, MODERATOR]) + """ + regenerateSSOKey will regenerate the SSO key used to sign secrets. This will + invalidate any existing user sessions. + """ + regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload + @auth(roles: [ADMIN]) + + """ + createOIDCAuthIntegration will create a OpenID Connect auth integration. + """ + createOIDCAuthIntegration( + input: CreateOIDCAuthIntegrationInput! + ): CreateOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) + + """ + updateOIDCAuthIntegration will update a given OpenID Connect auth integration. + """ + updateOIDCAuthIntegration( + input: UpdateOIDCAuthIntegrationInput! + ): UpdateOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) + + """ + deleteOIDCAuthIntegration will delete the specified OpenID Connect auth + integration. + """ + deleteOIDCAuthIntegration( + input: DeleteOIDCAuthIntegrationInput! + ): DeleteOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) + """ createCommentReaction will create a Reaction authored by the current logged in User on a Comment. diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 6434f30c8..4091b8f37 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -6,7 +6,7 @@ import { GQLKarma, GQLMODERATION_MODE, GQLReactionConfiguration, - GQLWordlist, + GQLWordList, } from "talk-server/graph/tenant/schema/__generated__/types"; // export interface EmailDomainRuleCondition { @@ -89,9 +89,9 @@ export interface Settings extends ModerationSettings { karma: GQLKarma; /** - * wordlist stores all the banned/suspect words. + * wordList stores all the banned/suspect words. */ - wordlist: GQLWordlist; + wordList: GQLWordList; /** * Set of configured authentication integrations. diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index aed12243d..56a425f04 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,9 +1,13 @@ +import crypto from "crypto"; import { Db } from "mongodb"; import uuid from "uuid"; import { DeepPartial, Omit, Sub } from "talk-common/types"; import { dotize } from "talk-common/utils/dotize"; -import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLMODERATION_MODE, + GQLOIDCAuthIntegration, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { Settings } from "talk-server/models/settings"; function collection(db: Db) { @@ -74,7 +78,7 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { charCount: { enabled: false, }, - wordlist: { + wordList: { suspect: [], banned: [], }, @@ -82,18 +86,20 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { integrations: { local: { enabled: true, + allowRegistration: true, }, sso: { enabled: false, + allowRegistration: false, }, - oidc: { - enabled: false, - }, + oidc: [], google: { enabled: false, + allowRegistration: false, }, facebook: { enabled: false, + allowRegistration: false, }, }, }, @@ -203,3 +209,203 @@ export async function updateTenant( return result.value || null; } + +/** + * regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On + * for the specified Tenant. All existing user sessions signed with the old + * secret will be invalidated. + */ +export async function regenerateTenantSSOKey(db: Db, id: string) { + // Generate a new key. We generate a key of minimum length 32 up to 37 bytes, + // as 16 was the minimum length recommended. + // + // Reference: https://security.stackexchange.com/a/96176 + const key = crypto + .randomBytes(32 + Math.floor(Math.random() * 5)) + .toString("hex"); + + // Construct the update. + const update: DeepPartial = { + auth: { + integrations: { + sso: { + key, + keyGeneratedAt: new Date(), + }, + }, + }, + }; + + // Update the Tenant with this new key. + const result = await collection(db).findOneAndUpdate( + { id }, + // Serialize the deep update into the Tenant. + { + $set: dotize(update), + }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + + return result.value || null; +} + +export type CreateTenantOIDCAuthIntegrationInput = Omit< + GQLOIDCAuthIntegration, + "id" | "callbackURL" +>; + +export interface CreateTenantOIDCAuthIntegrationResultObject { + tenant?: Tenant; + integration?: Omit; + wasCreated: boolean; +} + +export async function createTenantOIDCAuthIntegration( + mongo: Db, + id: string, + input: CreateTenantOIDCAuthIntegrationInput +): Promise { + // Add the ID to the integration. + const integration = { + id: uuid.v4(), + ...input, + }; + + const result = await collection(mongo).findOneAndUpdate( + { id }, + // Serialize the deep update into the Tenant. + { + $push: { "auth.integrations.oidc": integration }, + }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + if (!result.value) { + return { + wasCreated: false, + }; + } + + const wasCreated = + result.value.auth.integrations.oidc.findIndex( + ({ id: integrationID }) => integrationID === integration.id + ) !== -1; + + return { + tenant: result.value, + integration, + wasCreated, + }; +} + +export type UpdateTenantOIDCAuthIntegrationInput = Partial< + Omit +>; + +export interface UpdateTenantOIDCAuthIntegrationResultObject { + tenant?: Tenant; + integration?: Omit; + wasUpdated: boolean; +} + +export async function updateTenantOIDCAuthIntegration( + mongo: Db, + id: string, + oidcID: string, + input: UpdateTenantOIDCAuthIntegrationInput +): Promise { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $set: dotize({ + "auth.integrations.oidc.$[oidc]": input, + }), + }, + { + // Add an ArrayFilter to only update one of the OpenID Connect + // integrations. + arrayFilters: [{ "oidc.id": oidcID }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + return { + wasUpdated: false, + }; + } + + const integration = result.value.auth.integrations.oidc.find( + ({ id: integrationID }) => integrationID === oidcID + ); + + const wasUpdated = Boolean(integration); + + return { + tenant: result.value, + integration, + wasUpdated, + }; +} + +export interface DeleteTenantOIDCAuthIntegrationResultObject { + tenant?: Tenant; + integration?: Omit; + wasDeleted: boolean; +} + +/** + * deleteTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth + * Integration on the Tenant. + * + * @param mongo MongoDB Database handle + * @param id the id of the Tenant + * @param oidcID the id of the OpenID Connect Auth Integration we're deleting + */ +export async function deleteTenantOIDCAuthIntegration( + mongo: Db, + id: string, + oidcID: string +): Promise { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { "auth.integrations.oidc": { id: oidcID } }, + }, + { + // True to return the document before we modified it. This gives us the + // opportunity to return the original document and asertain if the + // integration was/could be removed. + returnOriginal: true, + } + ); + if (!result.value) { + return { wasDeleted: false }; + } + + // Find the integration that we wanted to delete. + const integration = result.value.auth.integrations.oidc.find( + ({ id: integrationID }) => integrationID === oidcID + ); + if (!integration) { + // The integration was not in the original document, so we could not have + // possibly deleted it! + return { wasDeleted: false }; + } + + // The integration was found, we should pull that integration out of the + // resulting Tenant. + result.value.auth.integrations.oidc.filter( + ({ id: integrationID }) => integrationID !== integration.id + ); + + return { + tenant: result.value, + integration, + wasDeleted: true, + }; +} diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index a89d3b42a..52cfc74c1 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -32,7 +32,22 @@ export interface SSOProfile { id: string; } -export type Profile = LocalProfile | OIDCProfile | SSOProfile; +export interface FacebookProfile { + type: "facebook"; + id: string; +} + +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/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 4339f6d73..89a06da38 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -92,7 +92,6 @@ export async function create( // Insert and handle creating the actions. comment = await addCommentActions(mongo, tenant, comment, inputs); - // asse } if (input.parent_id) { diff --git a/src/core/server/services/comments/moderation/phases/index.ts b/src/core/server/services/comments/moderation/phases/index.ts index 7617f2736..595436f39 100644 --- a/src/core/server/services/comments/moderation/phases/index.ts +++ b/src/core/server/services/comments/moderation/phases/index.ts @@ -1,15 +1,15 @@ import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; -import { premod } from "talk-server/services/comments/moderation/phases/premod"; -import { toxic } from "talk-server/services/comments/moderation/phases/toxic"; import { commentingDisabled } from "./commentingDisabled"; import { commentLength } from "./commentLength"; import { karma } from "./karma"; import { links } from "./links"; +import { preModerate } from "./preModerate"; import { spam } from "./spam"; import { staff } from "./staff"; import { storyClosed } from "./storyClosed"; -import { wordlist } from "./wordlist"; +import { toxic } from "./toxic"; +import { wordList } from "./wordList"; /** * The moderation phases to apply for each comment being processed. @@ -18,11 +18,11 @@ export const moderationPhases: IntermediateModerationPhase[] = [ commentLength, storyClosed, commentingDisabled, - wordlist, + wordList, staff, links, karma, spam, toxic, - premod, + preModerate, ]; diff --git a/src/core/server/services/comments/moderation/phases/premod.ts b/src/core/server/services/comments/moderation/phases/preModerate.ts similarity index 94% rename from src/core/server/services/comments/moderation/phases/premod.ts rename to src/core/server/services/comments/moderation/phases/preModerate.ts index 504bd235b..130112aef 100755 --- a/src/core/server/services/comments/moderation/phases/premod.ts +++ b/src/core/server/services/comments/moderation/phases/preModerate.ts @@ -13,7 +13,7 @@ const testModerationMode = (settings: Partial) => // This phase checks to see if the settings have premod enabled, if they do, // the comment is premod, otherwise, it's just none. -export const premod: IntermediateModerationPhase = ({ +export const preModerate: IntermediateModerationPhase = ({ story, tenant, }): IntermediatePhaseResult | void => { diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordList.ts similarity index 73% rename from src/core/server/services/comments/moderation/phases/wordlist.ts rename to src/core/server/services/comments/moderation/phases/wordList.ts index 4df8b39e1..c35458d15 100755 --- a/src/core/server/services/comments/moderation/phases/wordlist.ts +++ b/src/core/server/services/comments/moderation/phases/wordList.ts @@ -7,10 +7,10 @@ import { IntermediateModerationPhase, IntermediatePhaseResult, } from "talk-server/services/comments/moderation"; -import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist"; +import { containsMatchingPhraseMemoized } from "talk-server/services/comments/moderation/wordList"; -// This phase checks the comment against the wordlist. -export const wordlist: IntermediateModerationPhase = ({ +// This phase checks the comment against the wordList. +export const wordList: IntermediateModerationPhase = ({ tenant, comment, }): IntermediatePhaseResult | void => { @@ -21,9 +21,9 @@ export const wordlist: IntermediateModerationPhase = ({ // Decide the status based on whether or not the current story/settings // has pre-mod enabled or not. If the comment was rejected based on the - // wordlist, then reject it, otherwise if the moderation setting is + // wordList, then reject it, otherwise if the moderation setting is // premod, set it to `premod`. - if (containsMatchingPhrase(tenant.wordlist.banned, comment.body)) { + if (containsMatchingPhraseMemoized(tenant.wordList.banned, comment.body)) { // Add the flag related to Trust to the comment. return { status: GQLCOMMENT_STATUS.REJECTED, @@ -40,9 +40,9 @@ export const wordlist: IntermediateModerationPhase = ({ // flag to it to indicate that it needs to be looked at. // Otherwise just return the new comment. - // If the wordlist has matched the suspect word filter and we haven't disabled + // If the wordList has matched the suspect word filter and we haven't disabled // auto-flagging suspect words, then we should flag the comment! - if (containsMatchingPhrase(tenant.wordlist.suspect, comment.body)) { + if (containsMatchingPhraseMemoized(tenant.wordList.suspect, comment.body)) { return { actions: [ { diff --git a/src/core/server/services/comments/moderation/wordlist.spec.ts b/src/core/server/services/comments/moderation/wordList.spec.ts similarity index 97% rename from src/core/server/services/comments/moderation/wordlist.spec.ts rename to src/core/server/services/comments/moderation/wordList.spec.ts index d23705ecf..5e68398b1 100644 --- a/src/core/server/services/comments/moderation/wordlist.spec.ts +++ b/src/core/server/services/comments/moderation/wordList.spec.ts @@ -1,4 +1,4 @@ -import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist"; +import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordList"; const phrases = [ "cookies", diff --git a/src/core/server/services/comments/moderation/wordlist.ts b/src/core/server/services/comments/moderation/wordList.ts similarity index 61% rename from src/core/server/services/comments/moderation/wordlist.ts rename to src/core/server/services/comments/moderation/wordList.ts index 30cc124ac..47f50d09e 100644 --- a/src/core/server/services/comments/moderation/wordlist.ts +++ b/src/core/server/services/comments/moderation/wordList.ts @@ -1,3 +1,9 @@ +import { memoize } from "lodash"; + +// TODO: reintroduce this when we have https://github.com/DefinitelyTyped/DefinitelyTyped/pull/30035 merged +// // Replace `memoize.Cache`. +// memoize.Cache = WeakMap; + /** * Escape string for special regular expression characters. */ @@ -21,5 +27,13 @@ export function generateRegExp(phrases: string[]) { return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, "iu"); } +export const generateRegExpMemoized = memoize(generateRegExp); + export const containsMatchingPhrase = (phrases: string[], testString: string) => phrases.length > 0 ? generateRegExp(phrases).test(testString) : false; + +export const containsMatchingPhraseMemoized = ( + phrases: string[], + testString: string +) => + phrases.length > 0 ? generateRegExpMemoized(phrases).test(testString) : false; diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 54ab1d5a0..0fa525616 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -1,14 +1,24 @@ import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { URL } from "url"; -import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLCreateOIDCAuthIntegrationConfigurationInput, + GQLSettingsInput, + GQLUpdateOIDCAuthIntegrationConfigurationInput, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { createTenant, CreateTenantInput, + createTenantOIDCAuthIntegration, + deleteTenantOIDCAuthIntegration, + regenerateTenantSSOKey, Tenant, updateTenant, + updateTenantOIDCAuthIntegration, } from "talk-server/models/tenant"; +import { discover } from "talk-server/app/middleware/passport/strategies/oidc/discover"; import logger from "talk-server/logger"; import TenantCache from "./cache"; @@ -76,3 +86,115 @@ export async function canInstall(cache: TenantCache) { export async function isInstalled(cache: TenantCache) { return (await cache.count()) > 0; } + +/** + * regenerateSSOKey will regenerate the Single Sign-On key for the specified + * Tenant and notify all other Tenant's connected that the Tenant was updated. + */ +export async function regenerateSSOKey( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant +) { + const updatedTenant = await regenerateTenantSSOKey(mongo, tenant.id); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} + +/** + * discoverOIDCConfiguration will discover the OpenID Connect configuration as + * is required by any OpenID Connect compatible service: + * + * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + * + * @param issuerString the issuer that should be used as the discovery root. + */ +export async function discoverOIDCConfiguration(issuerString: string) { + // Parse the issuer. + const issuer = new URL(issuerString); + + // Discover the configuration. + return discover(issuer); +} + +export type CreateOIDCAuthIntegration = GQLCreateOIDCAuthIntegrationConfigurationInput; + +export async function createOIDCAuthIntegration( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + input: CreateOIDCAuthIntegration +) { + // Create the integration. By default, the integration is disabled. + const result = await createTenantOIDCAuthIntegration(mongo, tenant.id, { + enabled: false, + allowRegistration: false, + ...input, + }); + if (!result.wasCreated || !result.tenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, result.tenant); + + return result; +} + +export type UpdateOIDCAuthIntegration = GQLUpdateOIDCAuthIntegrationConfigurationInput; + +export async function updateOIDCAuthIntegration( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + oidcID: string, + input: UpdateOIDCAuthIntegration +) { + // Update the integration. By default, the integration is disabled. + const result = await updateTenantOIDCAuthIntegration( + mongo, + tenant.id, + oidcID, + input + ); + if (!result.wasUpdated || !result.tenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, result.tenant); + + return result; +} + +export async function deleteOIDCAuthIntegration( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + oidcID: string +) { + // Delete the integration. By default, the integration is disabled. + const result = await deleteTenantOIDCAuthIntegration( + mongo, + tenant.id, + oidcID + ); + if (!result.wasDeleted || !result.tenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, result.tenant); + + return result; +} 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; + } +} diff --git a/src/types/webfinger.d.ts b/src/types/webfinger.d.ts deleted file mode 100644 index a13bfb998..000000000 --- a/src/types/webfinger.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare module "webfinger" { - export interface WebfingerOptions { - webfingerOnly?: boolean; - } - - export interface WebfingerCallback { - (err: Error, jrd: { [key: string]: any }): void; - } - - export function webfinger( - resource: string, - res: string, - options: WebfingerOptions, - callback: WebfingerCallback - ): void; -}