mirror of
https://github.com/wassname/talk.git
synced 2026-08-03 13:20:59 +08:00
[next] Email (#2261)
* feat: suspending, banning, now propogation * feat: added email rendering + localization support * fix: fix related to lib * refactor: moved juicer to queue task * refactor: cleanup of job processor * refactor: improved error messaging around failed email * feat: initial forgot passwor impl * fix: fixed rebase errors * feat: send back Content-Language header with requests * feat: added ban email * feat: implemented forgotten password API * fix: linting * feat: support more emails * fix: promise patches * feat: initial confirm email API * feat: added rate limiting * feat: added URL support * feat: added email docs * fix: updated docs * chore: documentation review * fix: fixed build bug * feat: implement forgot password in auth popup * test: add tests + fixes * chore: rename StatelessComponent to FunctionComponent * fix: types and test fixes * chore: upgrade deps * fix: THANK YOU TESTS FOR SAVING MY A** * chore: reorder imports * chore: remove obsolete ! * feat: implement accounts bundle * refactor: review suggestion * fix: rebase upgrade error * fix: rebase bug * feat: reset password link support * test: add tests for account password reset page * fix: remove redirect uri * fix: revert local state changes
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
prefixSchemeIfRequired,
|
||||
} from "talk-server/app/url";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { isURLPermitted } from "talk-server/services/stories";
|
||||
import { isURLPermitted } from "talk-server/services/tenant/url";
|
||||
import { Request, RequestHandler } from "talk-server/types/express";
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import express from "express";
|
||||
|
||||
export const jsonMiddleware = express.json({});
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
extractJWTFromRequest,
|
||||
JWTSigningConfig,
|
||||
revokeJWT,
|
||||
SigningTokenOptions,
|
||||
signTokenString,
|
||||
} from "talk-server/services/jwt";
|
||||
import { Request } from "talk-server/types/express";
|
||||
@@ -109,18 +108,11 @@ export async function handleSuccessfulLogin(
|
||||
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;
|
||||
}
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.talk!.tenant!;
|
||||
|
||||
// Grab the token.
|
||||
const token = await signTokenString(signingConfig, user, options);
|
||||
const token = await signTokenString(signingConfig, user, tenant);
|
||||
|
||||
// Set the cache control headers.
|
||||
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
||||
@@ -152,18 +144,11 @@ export async function handleOAuth2Callback(
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.talk!.tenant!;
|
||||
|
||||
// Grab the token.
|
||||
const token = await signTokenString(signingConfig, user, options);
|
||||
const token = await signTokenString(signingConfig, user, tenant);
|
||||
|
||||
// Send back the details!
|
||||
res.redirect(path + `#accessToken=${token}`);
|
||||
@@ -190,8 +175,12 @@ export const wrapOAuth2Authn = (
|
||||
authenticator.authenticate(
|
||||
name,
|
||||
{ ...options, session: false },
|
||||
(err: Error | null, user: User | null) => {
|
||||
handleOAuth2Callback(err, user, signingConfig, req, res);
|
||||
async (err: Error | null, user: User | null) => {
|
||||
try {
|
||||
await handleOAuth2Callback(err, user, signingConfig, req, res);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
)(req, res, next);
|
||||
|
||||
@@ -213,7 +202,7 @@ export const wrapAuthn = (
|
||||
authenticator.authenticate(
|
||||
name,
|
||||
{ ...options, session: false },
|
||||
(err: Error | null, user: User | null) => {
|
||||
async (err: Error | null, user: User | null) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -221,8 +210,12 @@ export const wrapAuthn = (
|
||||
return next(new AuthenticationError("user not on request"));
|
||||
}
|
||||
|
||||
// Pass the login off to be signed.
|
||||
handleSuccessfulLogin(user, signingConfig, req, res, next);
|
||||
try {
|
||||
// Pass the login off to be signed.
|
||||
await handleSuccessfulLogin(user, signingConfig, req, res, next);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
)(req, res, next);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
|
||||
@@ -52,7 +52,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Db } from "mongodb";
|
||||
import { Strategy as LocalStrategy } from "passport-local";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
import { VerifyCallback } from "talk-server/app/middleware/passport";
|
||||
import { RequestLimiter } from "talk-server/app/request/limiter";
|
||||
import { InvalidCredentialsError } from "talk-server/errors";
|
||||
import {
|
||||
retrieveUserWithProfile,
|
||||
@@ -9,14 +11,19 @@ import {
|
||||
} from "talk-server/models/user";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
const verifyFactory = (mongo: Db) => async (
|
||||
const verifyFactory = (
|
||||
mongo: Db,
|
||||
ipLimiter: RequestLimiter,
|
||||
emailLimiter: RequestLimiter
|
||||
) => async (
|
||||
req: Request,
|
||||
email: string,
|
||||
password: string,
|
||||
done: VerifyCallback
|
||||
) => {
|
||||
try {
|
||||
// TODO: rate limit based on the IP address and user agent.
|
||||
await ipLimiter.test(req, req.ip);
|
||||
await emailLimiter.test(req, email);
|
||||
|
||||
// The tenant is guaranteed at this point.
|
||||
const tenant = req.talk!.tenant!;
|
||||
@@ -45,9 +52,26 @@ const verifyFactory = (mongo: Db) => async (
|
||||
|
||||
export interface LocalStrategyOptions {
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
|
||||
export function createLocalStrategy({
|
||||
mongo,
|
||||
redis: client,
|
||||
}: LocalStrategyOptions) {
|
||||
const ipLimiter = new RequestLimiter({
|
||||
client,
|
||||
ttl: "10m",
|
||||
max: 10,
|
||||
prefix: "ip",
|
||||
});
|
||||
const emailLimiter = new RequestLimiter({
|
||||
client,
|
||||
ttl: "10m",
|
||||
max: 10,
|
||||
prefix: "email",
|
||||
});
|
||||
|
||||
return new LocalStrategy(
|
||||
{
|
||||
usernameField: "email",
|
||||
@@ -55,6 +79,6 @@ export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
|
||||
session: false,
|
||||
passReqToCallback: true,
|
||||
},
|
||||
verifyFactory(mongo)
|
||||
verifyFactory(mongo, ipLimiter, emailLimiter)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Strategy } from "passport-strategy";
|
||||
import { Profile } from "passport";
|
||||
import { VerifyCallback } from "passport-oauth2";
|
||||
import { Config } from "talk-server/config";
|
||||
import { IntegrationDisabled } from "talk-server/errors";
|
||||
import { AuthIntegrations } from "talk-server/models/settings";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
@@ -28,6 +29,7 @@ export default abstract class OAuth2Strategy<
|
||||
T extends OAuth2Integration,
|
||||
U extends Strategy
|
||||
> extends Strategy {
|
||||
public abstract name: string;
|
||||
protected config: Config;
|
||||
protected mongo: Db;
|
||||
protected cache: TenantCacheAdapter<U>;
|
||||
@@ -59,7 +61,7 @@ export default abstract class OAuth2Strategy<
|
||||
integration: Required<T>,
|
||||
profile: Profile,
|
||||
now: Date
|
||||
): Promise<User | undefined>;
|
||||
): Promise<User | null | undefined>;
|
||||
|
||||
protected verifyCallback = async (
|
||||
req: Request,
|
||||
@@ -83,6 +85,9 @@ export default abstract class OAuth2Strategy<
|
||||
profile,
|
||||
now
|
||||
);
|
||||
if (!user) {
|
||||
return done(null);
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
@@ -100,8 +105,7 @@ export default abstract class OAuth2Strategy<
|
||||
|
||||
// Check to see if the integration is enabled.
|
||||
if (!integration.enabled) {
|
||||
// TODO: return a better error.
|
||||
throw new Error("integration not enabled");
|
||||
throw new IntegrationDisabled(this.name);
|
||||
}
|
||||
|
||||
if (!integration.clientID) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
import jwks, { JwksClient } from "jwks-rsa";
|
||||
import { isNil } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { reconstructURL } from "talk-server/app/url";
|
||||
import { IntegrationDisabled, TokenInvalidError } from "talk-server/errors";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import { OIDCAuthIntegration } from "talk-server/models/settings";
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
retrieveUserWithProfile,
|
||||
User,
|
||||
} from "talk-server/models/user";
|
||||
import { AsymmetricSigningAlgorithm } from "talk-server/services/jwt";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
|
||||
import { insert } from "talk-server/services/users";
|
||||
@@ -43,21 +46,38 @@ export interface OIDCIDToken {
|
||||
preferred_username?: string;
|
||||
}
|
||||
|
||||
export const OIDCIDTokenSchema = Joi.object()
|
||||
.keys({
|
||||
sub: Joi.string().required(),
|
||||
iss: Joi.string().required(),
|
||||
aud: Joi.string().required(),
|
||||
email: Joi.string().required(),
|
||||
email_verified: Joi.boolean().default(false),
|
||||
picture: Joi.string().default(undefined),
|
||||
name: Joi.string().default(undefined),
|
||||
nickname: Joi.string().default(undefined),
|
||||
preferred_username: Joi.string().default(undefined),
|
||||
})
|
||||
.optionalKeys([
|
||||
"picture",
|
||||
"email_verified",
|
||||
"name",
|
||||
"nickname",
|
||||
"preferred_username",
|
||||
]);
|
||||
|
||||
export interface StrategyItem {
|
||||
strategy: OAuth2Strategy;
|
||||
jwksClient?: JwksClient;
|
||||
}
|
||||
|
||||
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
|
||||
if (
|
||||
(token as OIDCIDToken).iss &&
|
||||
(token as OIDCIDToken).sub &&
|
||||
(token as OIDCIDToken).aud
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
const { error } = Joi.validate(token, OIDCIDTokenSchema, {
|
||||
// OIDC ID tokens may contain many other fields we haven't seen.. We Just
|
||||
// need to check to see that it contains at least the fields we need.
|
||||
allowUnknown: true,
|
||||
});
|
||||
return isNil(error);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,8 +112,7 @@ export function getEnabledIntegration(
|
||||
integration: OIDCAuthIntegration
|
||||
): Required<OIDCAuthIntegration> {
|
||||
if (!integration.enabled) {
|
||||
// TODO: return a better error.
|
||||
throw new Error("integration not enabled");
|
||||
throw new IntegrationDisabled("oidc");
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -105,34 +124,13 @@ export function getEnabledIntegration(
|
||||
!integration.jwksURI ||
|
||||
!integration.issuer
|
||||
) {
|
||||
// TODO: return a better error.
|
||||
throw new Error("integration not configured");
|
||||
throw new IntegrationDisabled("oidc");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
|
||||
return integration as Required<OIDCAuthIntegration>;
|
||||
}
|
||||
|
||||
export const OIDCIDTokenSchema = Joi.object()
|
||||
.keys({
|
||||
sub: Joi.string(),
|
||||
iss: Joi.string(),
|
||||
aud: Joi.string(),
|
||||
email: Joi.string(),
|
||||
email_verified: Joi.boolean().default(false),
|
||||
picture: Joi.string().default(undefined),
|
||||
name: Joi.string().default(undefined),
|
||||
nickname: Joi.string().default(undefined),
|
||||
preferred_username: Joi.string().default(undefined),
|
||||
})
|
||||
.optionalKeys([
|
||||
"picture",
|
||||
"email_verified",
|
||||
"name",
|
||||
"nickname",
|
||||
"preferred_username",
|
||||
]);
|
||||
|
||||
export async function findOrCreateOIDCUser(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
@@ -200,25 +198,41 @@ export function findOrCreateOIDCUserWithToken(
|
||||
tenant: Tenant,
|
||||
client: JwksClient,
|
||||
integration: OIDCAuthIntegration,
|
||||
token: string,
|
||||
tokenString: string,
|
||||
now: Date
|
||||
) {
|
||||
return new Promise<Readonly<User> | null>((resolve, reject) => {
|
||||
logger.trace({ tenantID: tenant.id }, "verifying oidc id_token");
|
||||
jwt.verify(
|
||||
token,
|
||||
tokenString,
|
||||
signingKeyFactory(client),
|
||||
{
|
||||
issuer: integration.issuer,
|
||||
// FIXME: (wyattjoh) support additional algorithms.
|
||||
// Currently we're limited by the key retrieval factory to only support
|
||||
// RS256. Tracking available:
|
||||
//
|
||||
// https://github.com/auth0/node-jwks-rsa/issues/40
|
||||
// https://github.com/auth0/node-jwks-rsa/issues/50
|
||||
algorithms: [AsymmetricSigningAlgorithm.RS256],
|
||||
clockTimestamp: Math.floor(now.getTime() / 1000),
|
||||
},
|
||||
async (err, decoded) => {
|
||||
async (err, token) => {
|
||||
logger.trace(
|
||||
{ tenantID: tenant.id },
|
||||
"finished verifying oidc id_token"
|
||||
);
|
||||
if (err) {
|
||||
// TODO: wrap error?
|
||||
return reject(err);
|
||||
return reject(
|
||||
new TokenInvalidError(tokenString, "token validation error", err)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the token.
|
||||
if (typeof token === "string" || !isOIDCToken(token)) {
|
||||
return reject(
|
||||
new TokenInvalidError(tokenString, "token is not an OIDCToken")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -226,7 +240,7 @@ export function findOrCreateOIDCUserWithToken(
|
||||
mongo,
|
||||
tenant,
|
||||
integration,
|
||||
decoded as OIDCIDToken,
|
||||
token,
|
||||
now
|
||||
);
|
||||
return resolve(user);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import {
|
||||
JWTSigningConfig,
|
||||
signTokenString,
|
||||
SymmetricSigningAlgorithm,
|
||||
} from "talk-server/services/jwt";
|
||||
import { isJWTToken } from "./jwt";
|
||||
|
||||
// Create signing config.
|
||||
const config: JWTSigningConfig = {
|
||||
algorithm: SymmetricSigningAlgorithm.HS256,
|
||||
secret: "secret",
|
||||
};
|
||||
|
||||
it("validates a jwt token", async () => {
|
||||
const user = { id: "user-id" };
|
||||
const tenant = { id: "tenant-id" };
|
||||
|
||||
// Create the signed token string.
|
||||
const tokenString = await signTokenString(config, user, tenant);
|
||||
|
||||
// Verify that the token conforms to the JWT token schema.
|
||||
const token = jwt.decode(tokenString) as object;
|
||||
|
||||
expect(isJWTToken(token)).toBeTruthy();
|
||||
});
|
||||
@@ -1,39 +1,22 @@
|
||||
import { Redis } from "ioredis";
|
||||
import jwt from "jsonwebtoken";
|
||||
import Joi from "joi";
|
||||
import { isNil } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import now from "performance-now";
|
||||
|
||||
import logger from "talk-server/logger";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUser } from "talk-server/models/user";
|
||||
import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt";
|
||||
import {
|
||||
checkJWTRevoked,
|
||||
JWTSigningConfig,
|
||||
StandardClaims,
|
||||
verifyJWT,
|
||||
} from "talk-server/services/jwt";
|
||||
|
||||
import { Verifier } from "../jwt";
|
||||
|
||||
export interface JWTToken {
|
||||
/**
|
||||
* jti is the Token identifier. With normal login tokens, this is a randomly
|
||||
* generated uuid, which is added to a revoke list when the User "logs out".
|
||||
* For Personal Access Tokens, this is the Token identifier.
|
||||
*/
|
||||
jti: string;
|
||||
|
||||
/**
|
||||
* sub is the ID of the User that this Token is associated with.
|
||||
*/
|
||||
sub: string;
|
||||
|
||||
/**
|
||||
* iss is the ID of the Tenant that this Token is associated with.
|
||||
*/
|
||||
iss: string;
|
||||
|
||||
/**
|
||||
* exp is the optional expiry for the tokens. Personal Access Token's do not
|
||||
* have an expiry associated with them, hence why it's optional.
|
||||
*/
|
||||
exp?: number;
|
||||
|
||||
export interface JWTToken
|
||||
extends Required<Pick<StandardClaims, "jti" | "sub" | "iss" | "iat">>,
|
||||
Pick<StandardClaims, "exp"> {
|
||||
/**
|
||||
* pat, when true, indicates that this Token is a Personal Access Token, and
|
||||
* it's `jti` claim should be treated as the Token ID. These tokens cannot be
|
||||
@@ -42,30 +25,18 @@ export interface JWTToken {
|
||||
pat?: boolean;
|
||||
}
|
||||
|
||||
export const JWTTokenSchema = Joi.object().keys({
|
||||
jti: Joi.string().required(),
|
||||
sub: Joi.string().required(),
|
||||
iat: Joi.number().required(),
|
||||
iss: Joi.string().required(),
|
||||
exp: Joi.number(),
|
||||
pat: Joi.boolean(),
|
||||
});
|
||||
|
||||
export function isJWTToken(token: JWTToken | object): token is JWTToken {
|
||||
if (
|
||||
typeof (token as JWTToken).jti !== "string" ||
|
||||
typeof (token as JWTToken).sub !== "string" ||
|
||||
typeof (token as JWTToken).iss !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof (token as JWTToken).exp !== "undefined" &&
|
||||
typeof (token as JWTToken).exp !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof (token as JWTToken).pat !== "undefined" &&
|
||||
typeof (token as JWTToken).pat !== "boolean"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
const { error } = Joi.validate(token, JWTTokenSchema);
|
||||
return isNil(error);
|
||||
}
|
||||
|
||||
export interface JWTVerifierOptions {
|
||||
@@ -89,19 +60,14 @@ export class JWTVerifier implements Verifier<JWTToken> {
|
||||
return isJWTToken(token) && token.iss === tenant.id;
|
||||
}
|
||||
|
||||
public async verify(tokenString: string, token: JWTToken, tenant: Tenant) {
|
||||
const startTime = now();
|
||||
|
||||
public async verify(
|
||||
tokenString: string,
|
||||
token: JWTToken,
|
||||
tenant: Tenant,
|
||||
now: Date
|
||||
) {
|
||||
// Verify that the token is valid. This will throw an error if it isn't.
|
||||
jwt.verify(tokenString, this.signingConfig.secret, {
|
||||
issuer: tenant.id,
|
||||
algorithms: [this.signingConfig.algorithm],
|
||||
});
|
||||
|
||||
// Compute the end time.
|
||||
const responseTime = Math.round(now() - startTime);
|
||||
|
||||
logger.trace({ responseTime }, "jwt verification complete");
|
||||
verifyJWT(tokenString, this.signingConfig, now, { issuer: tenant.id });
|
||||
|
||||
// Check to see if this is a Personal Access Token, these tokens cannot be
|
||||
// revoked.
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("SSOUserProfileSchema", () => {
|
||||
};
|
||||
|
||||
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
|
||||
expect(isSSOToken({ user: profile })).toEqual(true);
|
||||
});
|
||||
|
||||
it("allows an empty avatar", () => {
|
||||
@@ -42,6 +43,7 @@ describe("SSOUserProfileSchema", () => {
|
||||
};
|
||||
|
||||
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
|
||||
expect(isSSOToken({ user: profile })).toEqual(true);
|
||||
});
|
||||
|
||||
it("allows a valid payload", () => {
|
||||
@@ -54,6 +56,7 @@ describe("SSOUserProfileSchema", () => {
|
||||
};
|
||||
|
||||
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
|
||||
expect(isSSOToken({ user: profile })).toEqual(true);
|
||||
});
|
||||
|
||||
it("allows an empty avatar", () => {
|
||||
@@ -65,6 +68,7 @@ describe("SSOUserProfileSchema", () => {
|
||||
};
|
||||
|
||||
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
|
||||
expect(isSSOToken({ user: profile })).toEqual(true);
|
||||
});
|
||||
|
||||
it("allows an empty displayName", () => {
|
||||
@@ -76,5 +80,6 @@ describe("SSOUserProfileSchema", () => {
|
||||
};
|
||||
|
||||
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
|
||||
expect(isSSOToken({ user: profile })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { isNil } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { IntegrationDisabled } from "talk-server/errors";
|
||||
import {
|
||||
GQLSSOAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
@@ -11,6 +12,7 @@ import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
|
||||
import { insert } from "talk-server/services/users";
|
||||
|
||||
import { SymmetricSigningAlgorithm, verifyJWT } from "talk-server/services/jwt";
|
||||
import { Verifier } from "../jwt";
|
||||
|
||||
export interface SSOStrategyOptions {
|
||||
@@ -30,14 +32,18 @@ export interface SSOToken {
|
||||
|
||||
export const SSOUserProfileSchema = Joi.object()
|
||||
.keys({
|
||||
id: Joi.string(),
|
||||
email: Joi.string(),
|
||||
username: Joi.string(),
|
||||
id: Joi.string().required(),
|
||||
email: Joi.string().required(),
|
||||
username: Joi.string().required(),
|
||||
avatar: Joi.string().default(undefined),
|
||||
displayName: Joi.string().default(undefined),
|
||||
})
|
||||
.optionalKeys(["avatar", "displayName"]);
|
||||
|
||||
export const SSOTokenSchema = Joi.object().keys({
|
||||
user: SSOUserProfileSchema.required(),
|
||||
});
|
||||
|
||||
export async function findOrCreateSSOUser(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
@@ -91,26 +97,9 @@ export async function findOrCreateSSOUser(
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* isSSOUserProfile will check if the given profile is a SSOUserProfile.
|
||||
*
|
||||
* @param profile the profile to check for the type
|
||||
*/
|
||||
export function isSSOUserProfile(
|
||||
profile: SSOUserProfile | object
|
||||
): profile is SSOUserProfile {
|
||||
return (
|
||||
typeof (profile as SSOUserProfile).id !== "undefined" &&
|
||||
typeof (profile as SSOUserProfile).email !== "undefined" &&
|
||||
typeof (profile as SSOUserProfile).username !== "undefined"
|
||||
);
|
||||
}
|
||||
|
||||
export function isSSOToken(token: SSOToken | object): token is SSOToken {
|
||||
return (
|
||||
typeof (token as SSOToken).user === "object" &&
|
||||
isSSOUserProfile((token as SSOToken).user)
|
||||
);
|
||||
const { error } = Joi.validate(token, SSOTokenSchema);
|
||||
return isNil(error);
|
||||
}
|
||||
|
||||
export interface SSOVerifierOptions {
|
||||
@@ -132,24 +121,28 @@ export class SSOVerifier implements Verifier<SSOToken> {
|
||||
tokenString: string,
|
||||
token: SSOToken,
|
||||
tenant: Tenant,
|
||||
now = new Date()
|
||||
now: Date
|
||||
) {
|
||||
const integration = tenant.auth.integrations.sso;
|
||||
if (!integration.enabled) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("integration not enabled");
|
||||
throw new IntegrationDisabled("sso");
|
||||
}
|
||||
|
||||
if (!integration.key) {
|
||||
throw new Error("integration key does not exist");
|
||||
}
|
||||
|
||||
// Verify that the token is valid. This will throw an error if it isn't.
|
||||
jwt.verify(tokenString, integration.key, {
|
||||
// Force the use of the HS256 algorithm. We can explore switching this
|
||||
// out in the future..
|
||||
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
|
||||
});
|
||||
verifyJWT(
|
||||
tokenString,
|
||||
{
|
||||
// Force the use of the HS256 algorithm. We can explore switching this
|
||||
// out in the future..
|
||||
// TODO: (wyattjoh) investigate replacing algorithm.
|
||||
algorithm: SymmetricSigningAlgorithm.HS256,
|
||||
secret: integration.key,
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
return findOrCreateSSOUser(this.mongo, tenant, integration, token, now);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import uuid from "uuid/v1";
|
||||
|
||||
import { TenantNotFoundError } from "talk-server/errors";
|
||||
import logger from "talk-server/logger";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { RequestHandler } from "talk-server/types/express";
|
||||
|
||||
@@ -25,7 +26,7 @@ export const tenantMiddleware = ({
|
||||
const now = new Date();
|
||||
|
||||
// Set Talk on the request.
|
||||
req.talk = { id, now };
|
||||
req.talk = { id, now, logger: logger.child({ traceID: id }) };
|
||||
}
|
||||
|
||||
// Set the Talk Tenant Cache on the request.
|
||||
@@ -49,6 +50,9 @@ export const tenantMiddleware = ({
|
||||
// Attach the tenant to the request.
|
||||
req.talk.tenant = tenant;
|
||||
|
||||
// Attach the tenant's language to the request.
|
||||
res.setHeader("Content-Language", tenant.locale);
|
||||
|
||||
// Attach the tenant to the view locals.
|
||||
res.locals.tenant = tenant;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user