mirror of
https://github.com/wassname/talk.git
synced 2026-07-05 16:45:49 +08:00
Merge pull request #2013 from coralproject/next-auth
[next] Authentication Improvements
This commit is contained in:
Generated
+29
-4
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GQLFacebookAuthIntegration>,
|
||||
{ id, photos, emails, displayName }: Profile
|
||||
) {
|
||||
// Create the user profile that will be used to lookup the User.
|
||||
const profile: FacebookProfile = {
|
||||
type: "facebook",
|
||||
id,
|
||||
};
|
||||
|
||||
let user = await retrieveUserWithProfile(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<GQLFacebookAuthIntegration>
|
||||
) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<GQLGoogleAuthIntegration>,
|
||||
{ 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<GQLGoogleAuthIntegration>
|
||||
) {
|
||||
return new Strategy(
|
||||
{
|
||||
clientID: integration.clientID,
|
||||
clientSecret: integration.clientSecret,
|
||||
callbackURL: constructTenantURL(
|
||||
this.config,
|
||||
tenant,
|
||||
"/api/tenant/auth/google/callback"
|
||||
),
|
||||
passReqToCallback: true,
|
||||
},
|
||||
this.verifyCallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<U>;
|
||||
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<T>
|
||||
): U;
|
||||
|
||||
protected abstract findOrCreateUser(
|
||||
tenant: Tenant,
|
||||
integration: Required<T>,
|
||||
profile: Profile
|
||||
): Promise<User | undefined>;
|
||||
|
||||
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<T>,
|
||||
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<T>);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DiscoveryConfiguration | null> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
+75
-46
@@ -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<GQLOIDCAuthIntegration> {
|
||||
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<GQLOIDCAuthIntegration>
|
||||
) {
|
||||
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<GQLOIDCAuthIntegration>;
|
||||
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");
|
||||
}
|
||||
+4
-9
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Tenant, "domain">,
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)))
|
||||
),
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,46 @@ const Mutation: GQLMutationTypeResolver<void> = {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
@@ -6,6 +6,8 @@ const Query: GQLQueryTypeResolver<void> = {
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Tenant> = {
|
||||
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<GQLOIDCAuthIntegration, "callbackURL">;
|
||||
wasCreated: boolean;
|
||||
}
|
||||
|
||||
export async function createTenantOIDCAuthIntegration(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantOIDCAuthIntegrationInput
|
||||
): Promise<CreateTenantOIDCAuthIntegrationResultObject> {
|
||||
// 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<GQLOIDCAuthIntegration, "id">
|
||||
>;
|
||||
|
||||
export interface UpdateTenantOIDCAuthIntegrationResultObject {
|
||||
tenant?: Tenant;
|
||||
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
|
||||
wasUpdated: boolean;
|
||||
}
|
||||
|
||||
export async function updateTenantOIDCAuthIntegration(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
oidcID: string,
|
||||
input: UpdateTenantOIDCAuthIntegrationInput
|
||||
): Promise<UpdateTenantOIDCAuthIntegrationResultObject> {
|
||||
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<GQLOIDCAuthIntegration, "callbackURL">;
|
||||
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<DeleteTenantOIDCAuthIntegrationResultObject> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const testModerationMode = (settings: Partial<ModerationSettings>) =>
|
||||
|
||||
// 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 => {
|
||||
+7
-7
@@ -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: [
|
||||
{
|
||||
+1
-1
@@ -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",
|
||||
+14
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
-16
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user