[next] Auth Popup v2 (#2101)

* feat: Implement new Sign In view

* feat: Move forgot + resetPassword to new design

* feat: Implement sign up with new design

* fix: narrow gutter

* test: add unit tests

* test: integration tests

* feat: support show / hide password

* feat: support oauth2 flow

* feat: add views for user completion

* feat: implement oauth2 sign up

* test: fix snapshots

* fix: lint

* fix: get more complete mutation response

* fix: removed array of OIDC integrations

* fix: renamed resolver function

* fix: adapt oidc client implementation

* fix: targetFilter should be stream on signup

* fix: removed unneeded message

* fix: moved password into local profile

* fix: made username optional, removed valid null value

* fix: linting

* fix: respect targetFilter

* feat: support user registration mutations

- Added `setUsername`
- Added `setEmail`
- Added `setPassword`
- Added `permit` to `@auth`
- Added `email` to `User`

* fix: fixed issue with query

* feat: added user password update

* feat: complete sign in mutation

* fix: adapt some rebasing gitches

* test: improve tests

* test: unittest for setting auth token

* fix: failing tests

* test: move most tests from enzyme to react-test-renderer

* fix: remove schema warnings in tests

* test: improve window mock

* test: test different social login configurations

* test: test social logins for sign up

* fix: use htmlFor instead of for

* test: more feature tests

* feat: always go through account completion

* test: feature test account completion

* feat: addtional account completion test

* Update start.ts

* chore: refactor auth token retrieval logic
This commit is contained in:
Kiwi
2018-12-20 22:32:04 +01:00
committed by GitHub
parent 326a10dc5d
commit 065cb4b03a
331 changed files with 12476 additions and 7163 deletions
@@ -67,13 +67,13 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
const profile: LocalProfile = {
id: email,
type: "local",
password,
};
// Create the new user.
const user = await upsert(options.db, tenant, {
email,
username,
password,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
@@ -14,7 +14,7 @@ import { Request } from "talk-server/types/express";
export interface TenantInstallBody {
tenant: Omit<InstallTenant, "domain">;
user: Required<Pick<UpsertUser, "username" | "email" | "password">>;
user: Required<Pick<UpsertUser, "username" | "email"> & { password: string }>;
}
const TenantInstallBodySchema = Joi.object().keys({
@@ -79,15 +79,15 @@ export const tenantInstallHandler = ({
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
id: email,
password,
};
// Create the first admin user.
await upsert(mongo, tenant, {
email,
username,
password,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
});
@@ -137,6 +137,90 @@ export async function handleSuccessfulLogin(
}
}
export async function handleOAuth2Callback(
user: User,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
) {
try {
// Talk is guaranteed at this point.
const { tenant } = req.talk!;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
}
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.header("Expires", "-1");
res.header("Pragma", "no-cache");
// Send back the details!
res.send(
`<html>
<head></head>
<body>
<script type="text/javascript">
const redirect = sessionStorage.getItem("authRedirectBackTo");
if (!redirect) {
var textnode = document.createTextNode("'authRedirectBackTo' not set in Session Storage");
document.body.appendChild(textnode);
}
else if (redirect[0] !== '/') {
var textnode = document.createTextNode("'authRedirectBackTo' must begin with '/'");
document.body.appendChild(textnode);
}
else {
location.href = \`\${redirect}#${token}\`;
}
</script>
</body>
</html>`
);
} catch (err) {
return next(err);
}
}
/**
* wrapCallbackAuthn will wrap a authenticators authenticate method with one that
* will render a redirect script for a valid login by a compatible strategy.
*
* @param authenticator the base authenticator instance
* @param signingConfig used to sign the tokens that are issued.
* @param name the name of the authenticator to use
* @param options any options to be passed to the authenticate call
*/
export const wrapOAuth2Authn = (
authenticator: passport.Authenticator,
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
if (!user) {
// TODO: (wyattjoh) replace with better error.
return next(new Error("no user on request"));
}
handleOAuth2Callback(user, signingConfig, req, res, next);
}
)(req, res, next);
/**
* wrapAuthn will wrap a authenticators authenticate method with one that
* will return a valid login token for a valid login by a compatible strategy.
@@ -67,7 +67,6 @@ export default class FacebookStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -80,7 +80,6 @@ export default class GoogleStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -4,7 +4,7 @@ import { Strategy } from "passport-strategy";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
import { Config } from "talk-server/config";
import { GQLAuthIntegrations } from "talk-server/graph/tenant/schema/__generated__/types";
import { AuthIntegrations } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
@@ -42,7 +42,7 @@ export default abstract class OAuth2Strategy<
this.scope = scope;
}
protected abstract getIntegration(integrations: GQLAuthIntegrations): T;
protected abstract getIntegration(integrations: AuthIntegrations): T;
protected abstract createStrategy(
tenant: Tenant,
@@ -39,13 +39,10 @@ export interface OIDCIDToken {
nickname?: string;
}
export type StrategyItem = Record<
string,
{
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
>;
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
@@ -88,24 +85,10 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
};
function getEnabledIntegration(
tenant: Tenant,
oidcID: string
tenant: Tenant
): Required<GQLOIDCAuthIntegration> {
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");
}
// Handle when the integration is enabled/disabled.
// Grab the OIDC Integration.
const integration = tenant.auth.integrations.oidc;
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
@@ -181,7 +164,6 @@ export async function findOrCreateOIDCUser(
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -224,24 +206,19 @@ export default class OIDCStrategy extends Strategy {
tenantID: string,
oidc: Required<GQLOIDCAuthIntegration>
): jwks.JwksClient {
let tenantIntegrations = this.cache.get(tenantID);
if (!tenantIntegrations || !tenantIntegrations[oidc.id]) {
let tenantIntegration = this.cache.get(tenantID);
if (!tenantIntegration) {
const strategy = this.createStrategy(req, oidc);
// Create the entry.
tenantIntegrations = {
[oidc.id]: {
strategy,
},
...(tenantIntegrations || {}),
tenantIntegration = {
strategy,
};
// 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.
}
const tenantIntegration = tenantIntegrations[oidc.id];
if (!tenantIntegration.jwksClient) {
// Create the new JWKS client.
const jwksClient = jwks({
@@ -252,13 +229,7 @@ export default class OIDCStrategy extends Strategy {
tenantIntegration.jwksClient = jwksClient;
// Update the cached entry.
this.cache.set(tenantID, {
[oidc.id]: {
...tenantIntegration,
jwksClient,
},
...tenantIntegrations,
});
this.cache.set(tenantID, tenantIntegration);
}
return tenantIntegration.jwksClient;
@@ -287,14 +258,11 @@ 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, oidcID);
integration = getEnabledIntegration(tenant);
} catch (err) {
// TODO: wrap error?
return done(err);
@@ -338,10 +306,7 @@ 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/${integration.id}/callback`
);
const callbackURL = reconstructURL(req, `/api/tenant/auth/oidc/callback`);
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
@@ -365,32 +330,26 @@ export default class OIDCStrategy extends Strategy {
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, oidcID);
const integration = getEnabledIntegration(tenant);
// Try to get the Tenant's cached integrations.
let tenantIntegrations = this.cache.get(tenant.id);
if (!tenantIntegrations || !tenantIntegrations[oidcID]) {
let tenantIntegration = this.cache.get(tenant.id);
if (!tenantIntegration) {
// Create the strategy.
const strategy = this.createStrategy(req, integration);
// Reset the entry.
tenantIntegrations = {
[oidcID]: {
strategy,
},
...(tenantIntegrations || {}),
tenantIntegration = {
strategy,
};
// Update the cached integrations value.
this.cache.set(tenant.id, tenantIntegrations);
this.cache.set(tenant.id, tenantIntegration);
}
return tenantIntegrations[oidcID].strategy;
return tenantIntegration.strategy;
}
public authenticate(req: Request) {
@@ -18,7 +18,7 @@ export interface SSOStrategyOptions {
export interface SSOUserProfile {
id: string;
email: string;
username: string;
username?: string;
avatar?: string;
displayName?: string;
}
+10 -3
View File
@@ -5,7 +5,10 @@ import {
logoutHandler,
signupHandler,
} from "talk-server/app/handlers/api/tenant/auth/local";
import { wrapAuthn } from "talk-server/app/middleware/passport";
import {
wrapAuthn,
wrapOAuth2Authn,
} from "talk-server/app/middleware/passport";
import { RouterOptions } from "talk-server/app/router/types";
function wrapPath(
@@ -15,7 +18,11 @@ function wrapPath(
strategy: string,
path: string = `/${strategy}`
) {
const handler = wrapAuthn(options.passport, app.signingConfig, strategy);
const handler = wrapOAuth2Authn(
options.passport,
app.signingConfig,
strategy
);
router.get(path, handler);
router.get(path + "/callback", handler);
@@ -46,7 +53,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
// 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");
wrapPath(app, options, router, "oidc");
return router;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function constructTenantURL(
): 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")}`);
url = new URL(path, `http://${tenant.domain}:${config.get("dev_port")}`);
}
return url.href;
+7
View File
@@ -70,6 +70,13 @@ const config = convict({
env: "PORT",
arg: "port",
},
dev_port: {
doc: "The port to bind for the Webpack Dev Server.",
format: "port",
default: 8080,
env: "DEV_PORT",
arg: "dev-port",
},
mongodb: {
doc: "The MongoDB database to connect to.",
format: "mongo-uri",
@@ -1,19 +1,67 @@
import { DirectiveResolverFn } from "graphql-tools";
import { memoize } from "lodash";
import CommonContext from "talk-server/graph/common/context";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { User } from "talk-server/models/user";
// Replace `memoize.Cache`.
memoize.Cache = WeakMap;
export interface AuthDirectiveArgs {
roles?: GQLUSER_ROLE[];
userIDField?: string;
permit?: GQLUSER_AUTH_CONDITIONS[];
}
function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
const conditions: GQLUSER_AUTH_CONDITIONS[] = [];
if (!user.username && !user.displayName) {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_NAME);
}
if (!user.email) {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_EMAIL);
}
return conditions.sort();
}
const calculateAuthConditionsMemoized = memoize(calculateAuthConditions);
const auth: DirectiveResolverFn<
Record<string, string | undefined>,
CommonContext
> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => {
> = (
next,
src,
{ roles, userIDField, permit }: AuthDirectiveArgs,
{ user }
) => {
// If there is a user on the request.
if (user) {
// If the permit was not specified, then no conditions can exist on the
// User, if they do error.
const conditions = calculateAuthConditionsMemoized(user);
if (!permit && conditions.length > 0) {
// TODO: return better error.
throw new Error("not authorized");
}
// If the permit was specified, and some of the conditions for the user
// aren't in the list of permitted conditions, then error.
if (
permit &&
conditions.some(condition => permit.indexOf(condition) === -1)
) {
// TODO: return better error.
throw new Error("not authorized");
}
// If the role and user owner checks are disabled, then allow them based on
// their authenticated status.
if (!roles && !userIDField) {
@@ -1,20 +1,9 @@
import { isNull, omitBy } from "lodash";
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCreateOIDCAuthIntegrationInput,
GQLRemoveOIDCAuthIntegrationInput,
GQLSettingsInput,
GQLUpdateOIDCAuthIntegrationInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import {
createOIDCAuthIntegration,
regenerateSSOKey,
removeOIDCAuthIntegration,
update,
updateOIDCAuthIntegration,
} from "talk-server/services/tenant";
import { regenerateSSOKey, update } from "talk-server/services/tenant";
export const Settings = ({
mongo,
@@ -26,23 +15,4 @@ export const Settings = ({
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
),
removeOIDCAuthIntegration: (input: GQLRemoveOIDCAuthIntegrationInput) =>
removeOIDCAuthIntegration(mongo, redis, tenantCache, tenant, input.id),
});
@@ -0,0 +1,33 @@
import TenantContext from "talk-server/graph/tenant/context";
import * as user from "talk-server/models/user";
import {
setEmail,
setPassword,
setUsername,
updatePassword,
} from "talk-server/services/users";
import {
GQLSetEmailInput,
GQLSetPasswordInput,
GQLSetUsernameInput,
GQLUpdatePasswordInput,
} from "../schema/__generated__/types";
export const User = (ctx: TenantContext) => ({
setUsername: async (
input: GQLSetUsernameInput
): Promise<Readonly<user.User> | null> =>
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
setEmail: async (
input: GQLSetEmailInput
): Promise<Readonly<user.User> | null> =>
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
setPassword: async (
input: GQLSetPasswordInput
): Promise<Readonly<user.User> | null> =>
setPassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
updatePassword: async (
input: GQLUpdatePasswordInput
): Promise<Readonly<user.User> | null> =>
updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
});
@@ -4,10 +4,12 @@ import { Actions } from "./Actions";
import { Comment } from "./Comment";
import { Settings } from "./Settings";
import { Story } from "./Story";
import { User } from "./User";
export default (ctx: TenantContext) => ({
Actions: Actions(ctx),
Comment: Comment(ctx),
Settings: Settings(ctx),
Story: Story(ctx),
User: User(ctx),
});
@@ -1,24 +1,15 @@
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import {
GQLFacebookAuthIntegration,
GQLFacebookAuthIntegrationTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { reconstructTenantURLResolver } from "./util";
export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
GQLFacebookAuthIntegration
> = {
callbackURL: (integration, args, ctx) => {
const path = `/api/tenant/auth/facebook/callback`;
// 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);
},
callbackURL: reconstructTenantURLResolver(
"/api/tenant/auth/facebook/callback"
),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/facebook"),
};
@@ -1,24 +1,13 @@
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import {
GQLGoogleAuthIntegration,
GQLGoogleAuthIntegrationTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { reconstructTenantURLResolver } from "./util";
export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GQLGoogleAuthIntegration
> = {
callbackURL: (integration, args, ctx) => {
const path = `/api/tenant/auth/google/callback`;
// 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);
},
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/google"),
};
@@ -53,42 +53,6 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
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,
};
},
removeOIDCAuthIntegration: async (source, { input }, ctx) => {
const result = await ctx.mutators.Settings.removeOIDCAuthIntegration(input);
if (!result) {
return { clientMutationId: input.clientMutationId };
}
return {
integration: result.integration,
settings: result.tenant,
clientMutationId: input.clientMutationId,
};
},
createStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.create(input),
clientMutationId: input.clientMutationId,
@@ -117,4 +81,20 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
comment: await ctx.mutators.Actions.rejectComment(input),
clientMutationId: input.clientMutationId,
}),
setUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setUsername(input),
clientMutationId: input.clientMutationId,
}),
setEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setEmail(input),
clientMutationId: input.clientMutationId,
}),
setPassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setPassword(input),
clientMutationId: input.clientMutationId,
}),
updatePassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updatePassword(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -1,24 +1,13 @@
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import {
GQLOIDCAuthIntegration,
GQLOIDCAuthIntegrationTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { reconstructTenantURLResolver } from "./util";
export 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);
},
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/oidc"),
};
@@ -10,6 +10,10 @@ const resolveType: GQLProfileTypeResolver<user.Profile> = profile => {
return "OIDCProfile";
case "sso":
return "SSOProfile";
case "facebook":
return "FacebookProfile";
case "google":
return "GoogleProfile";
default:
// TODO: replace with better error.
throw new Error("invalid profile type");
@@ -4,6 +4,10 @@ import { pull } from "lodash";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { URL } from "url";
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import TenantContext from "../context";
/**
* getRequestedFields returns the fields in an array that are being queried for.
*
@@ -26,3 +30,18 @@ export function getURLWithCommentID(storyURL: string, commentID?: string) {
return url.toString();
}
export function reconstructTenantURLResolver<T = any>(path: string) {
return (parent: T, args: {}, ctx: TenantContext) => {
// 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);
};
}
+299 -287
View File
@@ -2,15 +2,41 @@
## Custom Directives
################################################################################
"""
USER_AUTH_CONDITIONS describes conditions that would prevent a given User to
execute any set of mutations or reserved queries.
"""
enum USER_AUTH_CONDITIONS {
"""
MISSING_NAME is provided when the User does not have an associated username or
display name.
"""
MISSING_NAME
"""
MISSING_EMAIL is provided when the User does not have an associated email
address.
"""
MISSING_EMAIL
}
"""
auth is a directive that will enforce authorization rules on the schema
definition. It will restrict the viewer of the field based on roles or if the
`userIDField` is specified, it will see if the current users ID equals the field
specified. This allows users that own a specific resource (like a comment, or a
flag) see their own content, but restrict it to everyone else. If the directive
is used without options, it simply requires a logged in user.
is used without options, it simply requires a logged in user. `permit` can be
used to allow specific `USER_AUTH_CONDITIONS` that normally (if present) would
deny access to any edge associated with the `@auth` directive. If a User has
only some of the conditions listed, they will pass, but if they have at least
one more that isn't in the list, the request will be denied.
"""
directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION
directive @auth(
roles: [USER_ROLE!]
userIDField: String
permit: [USER_AUTH_CONDITIONS!]
) on FIELD_DEFINITION
################################################################################
## Custom Scalar Types
@@ -413,11 +439,6 @@ 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.
"""
@@ -444,53 +465,60 @@ type OIDCAuthIntegration {
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,
callbackURL is the URL that the user should be redirected to in order to continue
the authentication flow with the given integration. This field is not stored,
and is instead computed from the Tenant.
"""
callbackURL: String!
"""
redirectURL 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.
"""
redirectURL: String
"""
clientID is the Client Identifier as defined in:
https://tools.ietf.org/html/rfc6749#section-2.2
"""
clientID: String! @auth(roles: [ADMIN])
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])
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])
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])
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])
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])
issuer: String @auth(roles: [ADMIN])
}
##########################
@@ -517,11 +545,18 @@ type GoogleAuthIntegration {
clientSecret: String @auth(roles: [ADMIN])
"""
callbackURL is the URL that the user should be redirected to in order to start
callbackURL is the URL that the user should be redirected to in order to continue
the authentication flow. This field is not stored, and is instead computed from
the Tenant.
"""
callbackURL: String!
"""
redirectURL 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.
"""
redirectURL: String!
}
##########################
@@ -548,11 +583,18 @@ type FacebookAuthIntegration {
clientSecret: String @auth(roles: [ADMIN])
"""
callbackURL is the URL that the user should be redirected to in order to start
callbackURL is the URL that the user should be redirected to in order to continue
the authentication flow. This field is not stored, and is instead computed from
the Tenant.
"""
callbackURL: String!
"""
redirectURL 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.
"""
redirectURL: String!
}
##########################
@@ -562,7 +604,7 @@ type FacebookAuthIntegration {
type AuthIntegrations {
local: LocalAuthIntegration!
sso: SSOAuthIntegration!
oidc: [OIDCAuthIntegration!]!
oidc: OIDCAuthIntegration!
google: GoogleAuthIntegration!
facebook: FacebookAuthIntegration!
}
@@ -990,7 +1032,20 @@ type SSOProfile {
id: String!
}
union Profile = LocalProfile | OIDCProfile | SSOProfile
type FacebookProfile {
id: String!
}
type GoogleProfile {
id: String!
}
union Profile =
LocalProfile
| OIDCProfile
| SSOProfile
| FacebookProfile
| GoogleProfile
"""
User is someone that leaves Comments, and logs in.
@@ -1011,10 +1066,25 @@ type User {
"""
displayName: String
"""
email is the current email address for the User.
"""
email: String
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL]
)
"""
profiles is the array of profiles assigned to the user.
"""
profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
profiles: [Profile!]!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL]
)
"""
role is the current role of the User.
@@ -1747,6 +1817,75 @@ type EditCommentPayload {
## updateSettings
##################
input SettingsOIDCAuthIntegrationInput {
"""
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 SettingsEmailInput {
"""
enabled when True, will enable the emailing functionality in Talk.
@@ -1858,6 +1997,7 @@ input SettingsFacebookAuthIntegrationInput {
input SettingsAuthIntegrationsInput {
local: SettingsLocalAuthIntegrationInput
sso: SettingsSSOAuthIntegrationInput
oidc: SettingsOIDCAuthIntegrationInput
google: SettingsGoogleAuthIntegrationInput
facebook: SettingsFacebookAuthIntegrationInput
}
@@ -2365,250 +2505,6 @@ type RegenerateSSOKeyPayload {
clientMutationId: String!
}
##################
# createOIDCAuthIntegration
##################
input CreateOIDCAuthIntegrationConfigurationInput {
"""
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 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!
}
##################
# removeOIDCAuthIntegration
##################
input RemoveOIDCAuthIntegrationInput {
"""
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 RemoveOIDCAuthIntegrationPayload {
"""
integration is the OIDCAuthIntegration we just removed.
"""
integration: OIDCAuthIntegration
"""
settings is the Settings that the OIDCAuthIntegration was removed on with the
OIDCAuthIntegration removed from it.
"""
settings: Settings
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createStory
##################
@@ -2935,6 +2831,118 @@ type RejectCommentPayload {
clientMutationId: String!
}
##################
# setUsername
##################
input SetUsernameInput {
"""
username is the desired username that should be set to the current User.
"""
username: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type SetUsernamePayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# setEmail
##################
input SetEmailInput {
"""
email is the email address to be associated with the User.
"""
email: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type SetEmailPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# setPassword
##################
input SetPasswordInput {
"""
password is the password that should be associated with the User.
"""
password: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type SetPasswordPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# updatePassword
##################
input UpdatePasswordInput {
"""
password is the new password that should be associated with the User.
"""
password: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdatePasswordPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -2943,7 +2951,7 @@ type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload
createComment(input: CreateCommentInput!): CreateCommentPayload @auth
"""
createCommentReply will create a Comment as the current logged in User that is
@@ -2970,28 +2978,6 @@ type Mutation {
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])
"""
removeOIDCAuthIntegration will remove the specified OpenID Connect auth
integration.
"""
removeOIDCAuthIntegration(
input: RemoveOIDCAuthIntegrationInput!
): RemoveOIDCAuthIntegrationPayload @auth(roles: [ADMIN])
"""
createCommentReaction will create a Reaction authored by the current logged in
User on a Comment.
@@ -3073,6 +3059,32 @@ type Mutation {
"""
rejectComment(input: RejectCommentInput!): RejectCommentPayload
@auth(roles: [MODERATOR, ADMIN])
"""
setUsername will set the username on the current User if they have not set one
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload
@auth(permit: [MISSING_NAME, MISSING_EMAIL])
"""
setEmail will set the email address on the current User if they have not set
one already. This mutation will fail if the email address is already set.
"""
setEmail(input: SetEmailInput!): SetEmailPayload
@auth(permit: [MISSING_EMAIL])
"""
setPassword will set the password on the current User if they have not set
one already. This mutation will fail if the password is already set.
"""
setPassword(input: SetPasswordInput!): SetPasswordPayload @auth
"""
updatePassword allows the current logged in User to change their password if
they already have one associated with them.
"""
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload @auth
}
################################################################################
+48 -43
View File
@@ -1,55 +1,20 @@
import { Omit } from "talk-common/types";
import {
GQLAuth,
GQLAuthDisplayNameConfiguration,
GQLCharCount,
GQLEmail,
GQLExternalIntegrations,
GQLFacebookAuthIntegration,
GQLGoogleAuthIntegration,
GQLKarma,
GQLLocalAuthIntegration,
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
GQLReactionConfiguration,
GQLSSOAuthIntegration,
GQLWordList,
} from "talk-server/graph/tenant/schema/__generated__/types";
// export interface EmailDomainRuleCondition {
// /**
// * emailDomain is the domain name component of the email addresses that should
// * match for this condition.
// */
// emailDomain: string;
// /**
// * emailVerifiedRequired stipulates that this rule only applies when the user
// * account has been marked as having their email address already verified.
// */
// emailVerifiedRequired: boolean;
// }
// /**
// * RoleRule describes the role assignment for when a user logs into Talk, how
// * they can have their account automatically upgraded to a specific role when
// * the domain for their email matches the one provided.
// */
// export interface RoleRule extends Partial<EmailDomainRuleCondition> {
// /**
// * role is the specific GQLUSER_ROLE that should be assigned to the newly
// * created user depending on their email address.
// */
// role: GQLUSER_ROLE;
// }
// export interface AuthRules {
// /**
// * roles allow the configuration of automatic role assignment based on the
// * user's email address.
// */
// roles?: RoleRule[];
// /**
// * restrictTo when populated, will restrict which users can login using this
// * integration. If a user successfully logs in using the OIDCStrategy, but
// * does not match the following rules, the user will not be created.
// */
// restrictTo?: EmailDomainRuleCondition[];
// }
export interface ModerationSettings {
moderation: GQLMODERATION_MODE;
requireEmailConfirmation: boolean;
@@ -67,6 +32,46 @@ export interface ModerationSettings {
charCount: GQLCharCount;
}
export type LocalAuthIntegration = GQLLocalAuthIntegration;
export type SSOAuthIntegration = GQLSSOAuthIntegration;
export type OIDCAuthIntegration = Omit<
GQLOIDCAuthIntegration,
"callbackURL" | "redirectURL"
>;
export type GoogleAuthIntegration = Omit<
GQLGoogleAuthIntegration,
"callbackURL" | "redirectURL"
>;
export type FacebookAuthIntegration = Omit<
GQLFacebookAuthIntegration,
"callbackURL" | "redirectURL"
>;
export interface AuthIntegrations {
local: LocalAuthIntegration;
sso: SSOAuthIntegration;
oidc: OIDCAuthIntegration;
google: GoogleAuthIntegration;
facebook: FacebookAuthIntegration;
}
export interface Auth {
/**
* integrations are the set of configurations for the variations of
* authentication solutions.
*/
integrations: AuthIntegrations;
/**
* displayName contains configuration related to the use of Display Names
* across AuthIntegrations.
*/
displayName: GQLAuthDisplayNameConfiguration;
}
export interface Settings extends ModerationSettings {
customCssUrl?: string;
@@ -96,7 +101,7 @@ export interface Settings extends ModerationSettings {
/**
* Set of configured authentication integrations.
*/
auth: GQLAuth;
auth: Auth;
/**
* Various integrations with external services.
+9 -166
View File
@@ -5,10 +5,7 @@ import uuid from "uuid";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize, DotizeOptions } from "talk-common/utils/dotize";
import {
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Settings } from "talk-server/models/settings";
function collection(db: Db) {
@@ -114,11 +111,17 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
key: generateSSOKey(),
keyGeneratedAt: new Date(),
},
oidc: [],
oidc: {
enabled: false,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
},
google: {
enabled: false,
allowRegistration: false,
callbackURL: "" as any, // FIXME: this should not be required
targetFilter: {
admin: true,
stream: true,
@@ -127,7 +130,6 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
facebook: {
enabled: false,
allowRegistration: false,
callbackURL: "", // FIXME: this should not be required
targetFilter: {
admin: true,
stream: true,
@@ -282,162 +284,3 @@ export async function regenerateTenantSSOKey(db: Db, id: string) {
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: dotizeDropNull({
"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 RemoveTenantOIDCAuthIntegrationResultObject {
tenant?: Tenant;
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
wasRemoved: boolean;
}
/**
* removeTenantOIDCAuthIntegration 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 removeTenantOIDCAuthIntegration(
mongo: Db,
id: string,
oidcID: string
): Promise<RemoveTenantOIDCAuthIntegrationResultObject> {
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 { wasRemoved: 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 { wasRemoved: 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,
wasRemoved: true,
};
}
+330 -16
View File
@@ -17,6 +17,7 @@ function collection(db: Db) {
export interface LocalProfile {
type: "local";
id: string;
password: string;
}
export interface OIDCProfile {
@@ -74,9 +75,9 @@ export interface UserStatus {
export interface User extends TenantResource {
readonly id: string;
username: string | null;
username?: string;
lowercaseUsername?: string;
displayName?: string;
password?: string;
avatar?: string;
email?: string;
emailVerified?: boolean;
@@ -88,9 +89,19 @@ export interface User extends TenantResource {
createdAt: Date;
}
function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export type UpsertUserInput = Omit<
User,
"id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt"
| "id"
| "tenantID"
| "tokens"
| "status"
| "ignoredUserIDs"
| "createdAt"
| "lowercaseUsername"
>;
export async function upsertUser(
@@ -129,20 +140,39 @@ export async function upsertUser(
createdAt: now,
};
let hashedPassword;
if (input.password) {
// Hash the user's password with bcrypt.
hashedPassword = await bcrypt.hash(input.password, 10);
// Mutate the profiles to ensure we mask handle any secrets.
const profiles: Profile[] = [];
for (let profile of input.profiles) {
switch (profile.type) {
case "local":
// FIXME: (wyattjoh) add password validation here.
// Hash the user's password with bcrypt.
const password = await hashPassword(profile.password);
profile = {
...profile,
password,
};
break;
}
// Save a copy.
profiles.push(profile);
}
// Add in the lowercase username if it was sent.
if (input.username) {
defaults.lowercaseUsername = input.username.toLowerCase();
// FIXME: (wyattjoh) add username checking regex here.
}
// FIXME: (wyattjoh) add email validation here.
// Merge the defaults and the input together.
const user: Readonly<User> = {
...defaults,
...input,
// Specified last in the merge call, it will override any existing password
// entry if it is defined.
password: hashedPassword,
profiles,
};
// Create a query that will utilize a findOneAndUpdate to facilitate an upsert
@@ -216,7 +246,7 @@ export async function retrieveManyUsers(
export async function retrieveUserWithProfile(
db: Db,
tenantID: string,
profile: Profile
profile: Partial<Profile>
) {
return collection(db).findOne({
tenantID,
@@ -235,16 +265,300 @@ export async function updateUserRole(
const result = await collection(db).findOneAndUpdate(
{ id, tenantID },
{ $set: { role } },
{ returnOriginal: false }
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
return result.value || null;
}
export async function verifyUserPassword(user: User, password: string) {
if (user.password) {
return bcrypt.compare(password, user.password);
const profile: LocalProfile | undefined = user.profiles.find(
({ type }) => type === "local"
) as LocalProfile | undefined;
if (!profile) {
throw new Error("no local profile exists for this user");
}
return false;
return bcrypt.compare(password, profile.password);
}
export async function updateUserPassword(
mongo: Db,
tenantID: string,
id: string,
password: string
) {
// FIXME: (wyattjoh) add password validation here.
// Hash the password.
const hashedPassword = await hashPassword(password);
// Update the user with the new password.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating already has a local
// profile associated with them.
"profiles.type": "local",
},
{
$set: {
"profiles.$[profiles].password": hashedPassword,
},
},
{
arrayFilters: [{ "profiles.type": "local" }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (
!user.profiles.some(
profile => profile.type === "local" && profile.id === user.email
)
) {
// TODO: (wyattjoh) return better error
throw new Error("user does not have a local profile");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value || null;
}
/**
* setUserUsername will set the username of the User if the username hasn't
* already been used before.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the username on
* @param username the username that we want to set
*/
export async function setUserUsername(
mongo: Db,
tenantID: string,
id: string,
username: string
) {
// Lowercase the username.
const lowercaseUsername = username.toLowerCase();
// FIXME: (wyattjoh) add username checking regex here.
// Search to see if this username has been used before.
let user = await collection(mongo).findOne({
tenantID,
lowercaseUsername,
});
if (user) {
// TODO: (wyattjoh) return better error
throw new Error("duplicate username found");
}
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
username: null,
},
{
$set: {
username,
lowercaseUsername,
"status.username.status": GQLUSER_USERNAME_STATUS.SET,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (user.username) {
// TODO: (wyattjoh) return better error
throw new Error("user already has username");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value;
}
/**
* setUserEmail will set the email address of the User if they don't already
* have one associated with them, and it hasn't been used before.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the email address on
* @param emailAddress the email address we want to set
*/
export async function setUserEmail(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// FIXME: (wyattjoh) add email validation here.
// Search to see if this email has been used before.
let user = await collection(mongo).findOne({
tenantID,
email,
});
if (user) {
// TODO: (wyattjoh) return better error
throw new Error("duplicate email found");
}
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
email: null,
},
{
$set: {
email,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (user.email) {
// TODO: (wyattjoh) return better error
throw new Error("user already has email");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value;
}
/**
* setUserLocalProfile will set the local profile for a User if they don't
* already have one associated with them and the profile doesn't exist on any
* other User already.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the local profile on
* @param emailAddress the email address we want to set
* @param password the password we want to set
*/
export async function setUserLocalProfile(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string,
password: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// FIXME: (wyattjoh) add email validation here.
// FIXME: (wyattjoh) add password validation here.
// Try to see if this local profile already exists on a User.
let user = await retrieveUserWithProfile(mongo, tenantID, {
type: "local",
id: email,
});
if (user) {
// TODO: (wyattjoh) return better error
throw new Error("duplicate profile found");
}
// Hash the password.
const hashedPassword = await hashPassword(password);
// Create the profile that we'll use.
const profile: LocalProfile = {
type: "local",
id: email,
password: hashedPassword,
};
// The profile wasn't found, so add it to the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating does not contain a local
// profile.
"profiles.type": { $ne: "local" },
},
{
$push: {
profiles: profile,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (user.profiles.some(({ type }) => type === "local")) {
// TODO: (wyattjoh) return better error
throw new Error("user already has local profile");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value;
}
+1 -87
View File
@@ -2,20 +2,13 @@ import { Redis } from "ioredis";
import { Db } from "mongodb";
import { URL } from "url";
import {
GQLCreateOIDCAuthIntegrationConfigurationInput,
GQLSettingsInput,
GQLUpdateOIDCAuthIntegrationConfigurationInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
import {
createTenant,
CreateTenantInput,
createTenantOIDCAuthIntegration,
regenerateTenantSSOKey,
removeTenantOIDCAuthIntegration,
Tenant,
updateTenant,
updateTenantOIDCAuthIntegration,
} from "talk-server/models/tenant";
import { discover } from "talk-server/app/middleware/passport/strategies/oidc/discover";
@@ -123,82 +116,3 @@ export async function discoverOIDCConfiguration(issuerString: string) {
// 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,
targetFilter: {
admin: true,
stream: true,
},
...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 removeOIDCAuthIntegration(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
oidcID: string
) {
// Delete the integration. By default, the integration is disabled.
const result = await removeTenantOIDCAuthIntegration(
mongo,
tenant.id,
oidcID
);
if (!result.wasRemoved || !result.tenant) {
return null;
}
// Update the tenant cache.
await cache.update(redis, result.tenant);
return result;
}
+80 -1
View File
@@ -1,7 +1,15 @@
import { Db } from "mongodb";
import { Tenant } from "talk-server/models/tenant";
import { upsertUser, UpsertUserInput } from "talk-server/models/user";
import {
setUserEmail,
setUserLocalProfile,
setUserUsername,
updateUserPassword,
upsertUser,
UpsertUserInput,
User,
} from "talk-server/models/user";
export type UpsertUser = UpsertUserInput;
@@ -10,3 +18,74 @@ export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) {
return user;
}
export async function setUsername(
mongo: Db,
tenant: Tenant,
user: User,
username: string
) {
// We require that the username is not defined in order to use this method.
if (user.username) {
throw new Error("username already associated with user");
}
return setUserUsername(mongo, tenant.id, user.id, username);
}
export async function setEmail(
mongo: Db,
tenant: Tenant,
user: User,
email: string
) {
// We requires that the email address is not defined in order to use this
// method.
if (user.email) {
throw new Error("email address already associated with user");
}
return setUserEmail(mongo, tenant.id, user.id, email);
}
export async function setPassword(
mongo: Db,
tenant: Tenant,
user: User,
password: string
) {
// We require that the email address for the user be defined for this method.
if (!user.email) {
throw new Error("no email address associated with user");
}
// We also don't allow this method to be used by users that already have a
// local profile.
if (user.profiles.some(({ type }) => type === "local")) {
throw new Error("user already has local profile");
}
return setUserLocalProfile(mongo, tenant.id, user.id, user.email, password);
}
export async function updatePassword(
mongo: Db,
tenant: Tenant,
user: User,
password: string
) {
// We require that the email address for the user be defined for this method.
if (!user.email) {
throw new Error("no email address associated with user");
}
// We also don't allow this method to be used by users that don't have a local
// profile already.
if (
!user.profiles.some(({ id, type }) => type === "local" && id === user.email)
) {
throw new Error("user does not have a local profile");
}
return updateUserPassword(mongo, tenant.id, user.id, password);
}