Merge pull request #2013 from coralproject/next-auth

[next] Authentication Improvements
This commit is contained in:
Kiwi
2018-11-01 16:42:25 +01:00
committed by GitHub
44 changed files with 1759 additions and 181 deletions
+29 -4
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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;
}
}
@@ -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");
}
@@ -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);
}
}
+19 -6
View File
@@ -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;
}
+1 -5
View File
@@ -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,
+18
View File
@@ -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");
}
+5 -1
View File
@@ -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;
}
},
});
+4 -2
View File
@@ -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;
+4 -1
View File
@@ -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.
+3 -3
View File
@@ -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.
+211 -5
View File
@@ -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,
};
}
+16 -1
View File
@@ -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,
];
@@ -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,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,4 +1,4 @@
import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist";
import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordList";
const phrases = [
"cookies",
@@ -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;
+123 -1
View File
@@ -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
View File
@@ -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;
}
}
-16
View File
@@ -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;
}