mirror of
https://github.com/wassname/talk.git
synced 2026-07-16 11:22:16 +08:00
[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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user