mirror of
https://github.com/wassname/talk.git
synced 2026-07-04 04:11:14 +08:00
Merge branch 'next' into admin
This commit is contained in:
@@ -7,10 +7,10 @@ import {
|
||||
handleLogout,
|
||||
handleSuccessfulLogin,
|
||||
} from "talk-server/app/middleware/passport";
|
||||
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { LocalProfile } from "talk-server/models/user";
|
||||
import { JWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ import nunjucks from "nunjucks";
|
||||
import path from "path";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
import { errorHandler } from "talk-server/app/middleware/error";
|
||||
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
|
||||
import { createPassport } from "talk-server/app/middleware/passport";
|
||||
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
|
||||
import { Schemas } from "talk-server/graph/schemas";
|
||||
import { JWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { TaskQueue } from "talk-server/services/queue";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
import { errorHandler } from "talk-server/app/middleware/error";
|
||||
import { accessLogger, errorLogger } from "./middleware/logging";
|
||||
import serveStatic from "./middleware/serveStatic";
|
||||
import { createRouter } from "./router";
|
||||
|
||||
@@ -6,19 +6,18 @@ import { Db } from "mongodb";
|
||||
import passport, { Authenticator } from "passport";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { JWTStrategy } from "talk-server/app/middleware/passport/strategies/jwt";
|
||||
import { createLocalStrategy } from "talk-server/app/middleware/passport/strategies/local";
|
||||
import OIDCStrategy from "talk-server/app/middleware/passport/strategies/oidc";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { User } from "talk-server/models/user";
|
||||
import {
|
||||
blacklistJWT,
|
||||
createJWTStrategy,
|
||||
extractJWTFromRequest,
|
||||
JWTSigningConfig,
|
||||
SigningTokenOptions,
|
||||
signTokenString,
|
||||
} from "talk-server/app/middleware/passport/jwt";
|
||||
import { createLocalStrategy } from "talk-server/app/middleware/passport/local";
|
||||
import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc";
|
||||
import { createSSOStrategy } from "talk-server/app/middleware/passport/sso";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { User } from "talk-server/models/user";
|
||||
} from "talk-server/services/jwt";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
@@ -42,17 +41,14 @@ export function createPassport(
|
||||
// Create the authenticator.
|
||||
const auth = new Authenticator();
|
||||
|
||||
// Use the OIDC Strategy.
|
||||
auth.use(createOIDCStrategy(options));
|
||||
|
||||
// Use the LocalStrategy.
|
||||
auth.use(createLocalStrategy(options));
|
||||
|
||||
// Use the SSOStrategy.
|
||||
auth.use(createSSOStrategy(options));
|
||||
// Use the OIDC Strategy.
|
||||
auth.use(new OIDCStrategy(options));
|
||||
|
||||
// Use the JWTStrategy.
|
||||
auth.use(createJWTStrategy(options));
|
||||
// Use the SSOStrategy.
|
||||
auth.use(new JWTStrategy(options));
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -114,9 +110,6 @@ export async function handleSuccessfulLogin(
|
||||
if (tenant) {
|
||||
// Attach the tenant's id to the issued token as a `iss` claim.
|
||||
options.issuer = tenant.id;
|
||||
|
||||
// TODO: (wyattjoh) evaluate the possibility when we have multiple
|
||||
// integrations per type to use the integration id as the audience.
|
||||
}
|
||||
|
||||
// Grab the token.
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import Joi from "joi";
|
||||
import jwt, { KeyFunctionCallback } from "jsonwebtoken";
|
||||
import { Db } from "mongodb";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt";
|
||||
import {
|
||||
findOrCreateOIDCUser,
|
||||
isOIDCToken,
|
||||
OIDCIDToken,
|
||||
} from "talk-server/app/middleware/passport/oidc";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface SSOStrategyOptions {
|
||||
mongo: Db;
|
||||
}
|
||||
|
||||
export interface SSOUserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface SSOToken {
|
||||
user: SSOUserProfile;
|
||||
}
|
||||
|
||||
export const SSOUserProfileSchema = Joi.object()
|
||||
.keys({
|
||||
id: Joi.string(),
|
||||
email: Joi.string(),
|
||||
username: Joi.string(),
|
||||
avatar: Joi.string().default(undefined),
|
||||
})
|
||||
.optionalKeys(["avatar"]);
|
||||
|
||||
export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({
|
||||
displayName: Joi.string().default(undefined),
|
||||
}).optionalKeys(["displayName"]);
|
||||
|
||||
export async function findOrCreateSSOUser(
|
||||
db: Db,
|
||||
tenant: Tenant,
|
||||
token: SSOToken
|
||||
) {
|
||||
if (!token.user) {
|
||||
// TODO: (wyattjoh) replace with better error.
|
||||
throw new Error("token is malformed, missing user claim");
|
||||
}
|
||||
|
||||
// Unpack/validate the token content.
|
||||
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
|
||||
tenant.auth.integrations.sso!.displayNameEnable
|
||||
? SSODisplayNameUserProfileSchema
|
||||
: SSOUserProfileSchema,
|
||||
token.user
|
||||
);
|
||||
|
||||
const profile: SSOProfile = {
|
||||
type: "sso",
|
||||
id,
|
||||
};
|
||||
|
||||
// Try to lookup user given their id provided in the `sub` claim.
|
||||
let user = await retrieveUserWithProfile(db, tenant.id, profile);
|
||||
if (!user) {
|
||||
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
|
||||
|
||||
// Create the new user, as one didn't exist before!
|
||||
user = await upsert(db, tenant, {
|
||||
username,
|
||||
// When the displayName is disabled on the tenant, the displayName will
|
||||
// never be set (or even stored in the database).
|
||||
displayName,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
avatar,
|
||||
profiles: [profile],
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export default class SSOStrategy extends Strategy {
|
||||
public name = "sso";
|
||||
|
||||
private mongo: Db;
|
||||
|
||||
constructor({ mongo }: SSOStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.mongo = mongo;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves the integration's secret to be used to verify the token.
|
||||
*/
|
||||
private getSigningSecretGetter = (tenant: Tenant) => async (
|
||||
headers: { kid?: string },
|
||||
done: KeyFunctionCallback
|
||||
) => {
|
||||
const integration = tenant.auth.integrations.sso;
|
||||
if (!integration) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
return done(new Error("integration not found"));
|
||||
}
|
||||
|
||||
if (!integration.enabled) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
return done(new Error("integration not enabled"));
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have?
|
||||
|
||||
return done(null, integration.key);
|
||||
};
|
||||
|
||||
/**
|
||||
* findOrCreateUser will interpret the token and use the correct strategy for
|
||||
* retrieving/creating the user.
|
||||
*
|
||||
* @param tenant the tenant for the new/returning user
|
||||
* @param token the token that was unpacked and validated from the sso strategy
|
||||
*/
|
||||
private async findOrCreateUser(
|
||||
tenant: Tenant,
|
||||
token: OIDCIDToken | SSOToken
|
||||
) {
|
||||
if (isOIDCToken(token)) {
|
||||
// The token provided for SSO contains an issuer claim. We're assuming
|
||||
// that this request is associated with an OpenID Connect provider.
|
||||
return findOrCreateOIDCUser(this.mongo, tenant, token);
|
||||
}
|
||||
|
||||
// Check to see if this token is a SSO Token or not, if it isn't error out.
|
||||
if (!isSSOToken(token)) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("token is invalid");
|
||||
}
|
||||
|
||||
// The token provided does not confirm to the OpenID Connect provider
|
||||
// spec, but id does conform to a SSOToken so we should expect the token to
|
||||
// contain the user profile.
|
||||
return findOrCreateSSOUser(this.mongo, tenant, token);
|
||||
}
|
||||
|
||||
public authenticate(req: Request) {
|
||||
const { tenant } = req;
|
||||
if (!tenant) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
return this.error(new Error("tenant not found"));
|
||||
}
|
||||
|
||||
// Lookup the token.
|
||||
const token = extractJWTFromRequest(req);
|
||||
if (!token) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
return this.fail(new Error("no token on request"), 400);
|
||||
}
|
||||
|
||||
// Perform the JWT validation.
|
||||
jwt.verify(
|
||||
token,
|
||||
this.getSigningSecretGetter(tenant),
|
||||
{
|
||||
// Force the use of the HS256 algorithm. We can explore switching this
|
||||
// out in the future..
|
||||
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
|
||||
},
|
||||
async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => {
|
||||
if (err) {
|
||||
// TODO: (wyattjoh) wrap error?
|
||||
return this.error(err);
|
||||
}
|
||||
|
||||
try {
|
||||
// Find or create the user based on the decoded token.
|
||||
const user = await this.findOrCreateUser(tenant, decoded);
|
||||
|
||||
// The user was found or created!
|
||||
return this.success(user, null);
|
||||
} catch (err) {
|
||||
return this.error(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSSOStrategy(options: SSOStrategyOptions) {
|
||||
return new SSOStrategy(options);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Redis } from "ioredis";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Db } from "mongodb";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import {
|
||||
JWTToken,
|
||||
JWTVerifier,
|
||||
} from "talk-server/app/middleware/passport/strategies/verifiers/jwt";
|
||||
import {
|
||||
SSOToken,
|
||||
SSOVerifier,
|
||||
} from "talk-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
import {
|
||||
extractJWTFromRequest,
|
||||
JWTSigningConfig,
|
||||
} from "talk-server/services/jwt";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface JWTStrategyOptions {
|
||||
signingConfig: JWTSigningConfig;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token is the various forms of the Token that can be verified.
|
||||
*/
|
||||
type Token = SSOToken | JWTToken | object | string | null;
|
||||
|
||||
/**
|
||||
* Verifier allows different implementations to offer ways to verify a given
|
||||
* Token.
|
||||
*/
|
||||
interface Verifier<T> {
|
||||
/**
|
||||
* verify will perform the verification and return a User.
|
||||
*/
|
||||
verify: (
|
||||
tokenString: string,
|
||||
token: T,
|
||||
tenant: Tenant
|
||||
) => Promise<Readonly<User> | null>;
|
||||
|
||||
/**
|
||||
* supports will perform type checking and ensure that the given Tenant
|
||||
* supports the requested verification type.
|
||||
*/
|
||||
supports: (token: T | object, tenant: Tenant) => token is T;
|
||||
}
|
||||
|
||||
export class JWTStrategy extends Strategy {
|
||||
public name = "jwt";
|
||||
|
||||
private verifiers: {
|
||||
sso: Verifier<SSOToken>;
|
||||
jwt: Verifier<JWTToken>;
|
||||
};
|
||||
|
||||
constructor(options: JWTStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.verifiers = {
|
||||
sso: new SSOVerifier(options),
|
||||
jwt: new JWTVerifier(options),
|
||||
};
|
||||
}
|
||||
|
||||
private async verify(tokenString: string, tenant: Tenant) {
|
||||
const token: Token = jwt.decode(tokenString);
|
||||
if (!token || typeof token === "string") {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("token could not be decoded");
|
||||
}
|
||||
|
||||
// Handle SSO integrations.
|
||||
if (this.verifiers.sso.supports(token, tenant)) {
|
||||
return this.verifiers.sso.verify(tokenString, token, tenant);
|
||||
}
|
||||
|
||||
// Handle the raw JWT token.
|
||||
if (this.verifiers.jwt.supports(token, tenant)) {
|
||||
// Verify the token with the JWT verification strategy.
|
||||
return this.verifiers.jwt.verify(tokenString, token, tenant);
|
||||
}
|
||||
|
||||
// No verifier could be found.
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("no suitable jwt verifier could be found");
|
||||
}
|
||||
|
||||
public async authenticate(req: Request) {
|
||||
// Get the token from the request.
|
||||
const token = extractJWTFromRequest(req);
|
||||
if (!token) {
|
||||
// There was no token on the request, so don't bother actually checking
|
||||
// anything further.
|
||||
return this.pass();
|
||||
}
|
||||
|
||||
const { tenant } = req;
|
||||
if (!tenant) {
|
||||
// TODO: (wyattjoh) log this error, and return a better one?
|
||||
return this.error(new Error("tenant not found"));
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.verify(token, tenant);
|
||||
if (!user) {
|
||||
return this.pass();
|
||||
}
|
||||
|
||||
return this.success(user, null);
|
||||
} catch (err) {
|
||||
// TODO: (wyattjoh) log this error
|
||||
return this.fail(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
OIDCDisplayNameIDTokenSchema,
|
||||
OIDCIDTokenSchema,
|
||||
} from "talk-server/app/middleware/passport/oidc";
|
||||
} from "talk-server/app/middleware/passport/strategies/oidc";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
|
||||
describe("OIDCIDTokenSchema", () => {
|
||||
-4
@@ -391,7 +391,3 @@ export default class OIDCStrategy extends Strategy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createOIDCStrategy(options: OIDCStrategyOptions) {
|
||||
return new OIDCStrategy(options);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Redis } from "ioredis";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUser } from "talk-server/models/user";
|
||||
import { checkBlacklistJWT, JWTSigningConfig } from "talk-server/services/jwt";
|
||||
|
||||
export interface JWTToken {
|
||||
// aud: string;
|
||||
jti: string;
|
||||
sub: string;
|
||||
exp: number;
|
||||
iss: string;
|
||||
}
|
||||
|
||||
export function isJWTToken(token: JWTToken | object): token is JWTToken {
|
||||
return (
|
||||
// typeof (token as JWTToken).aud === "string" &&
|
||||
typeof (token as JWTToken).jti === "string" &&
|
||||
typeof (token as JWTToken).sub === "string" &&
|
||||
typeof (token as JWTToken).exp === "number" &&
|
||||
typeof (token as JWTToken).iss === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export interface JWTVerifierOptions {
|
||||
signingConfig: JWTSigningConfig;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export class JWTVerifier {
|
||||
private signingConfig: JWTSigningConfig;
|
||||
private mongo: Db;
|
||||
private redis: Redis;
|
||||
|
||||
constructor({ signingConfig, mongo, redis }: JWTVerifierOptions) {
|
||||
this.signingConfig = signingConfig;
|
||||
this.mongo = mongo;
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
public supports(token: JWTToken | object, tenant: Tenant): token is JWTToken {
|
||||
return isJWTToken(token) && token.iss === tenant.id;
|
||||
}
|
||||
|
||||
public async verify(tokenString: string, token: JWTToken, tenant: Tenant) {
|
||||
// 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],
|
||||
});
|
||||
|
||||
// Check to see if the token has been blacklisted, as these tokens can be
|
||||
// revoked.
|
||||
await checkBlacklistJWT(this.redis, token.jti);
|
||||
|
||||
// Find the user.
|
||||
const user = await retrieveUser(this.mongo, tenant.id, token.sub);
|
||||
|
||||
// Return the user now that we have found them!.
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
||||
isSSOToken,
|
||||
SSODisplayNameUserProfileSchema,
|
||||
SSOUserProfileSchema,
|
||||
} from "talk-server/app/middleware/passport/sso";
|
||||
} from "talk-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
|
||||
describe("isSSOToken", () => {
|
||||
@@ -0,0 +1,143 @@
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
|
||||
export interface SSOStrategyOptions {
|
||||
mongo: Db;
|
||||
}
|
||||
|
||||
export interface SSOUserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface SSOToken {
|
||||
user: SSOUserProfile;
|
||||
}
|
||||
|
||||
export const SSOUserProfileSchema = Joi.object()
|
||||
.keys({
|
||||
id: Joi.string(),
|
||||
email: Joi.string(),
|
||||
username: Joi.string(),
|
||||
avatar: Joi.string().default(undefined),
|
||||
})
|
||||
.optionalKeys(["avatar"]);
|
||||
|
||||
export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({
|
||||
displayName: Joi.string().default(undefined),
|
||||
}).optionalKeys(["displayName"]);
|
||||
|
||||
export async function findOrCreateSSOUser(
|
||||
db: Db,
|
||||
tenant: Tenant,
|
||||
token: SSOToken
|
||||
) {
|
||||
if (!token.user) {
|
||||
// TODO: (wyattjoh) replace with better error.
|
||||
throw new Error("token is malformed, missing user claim");
|
||||
}
|
||||
|
||||
// Unpack/validate the token content.
|
||||
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
|
||||
tenant.auth.integrations.sso!.displayNameEnable
|
||||
? SSODisplayNameUserProfileSchema
|
||||
: SSOUserProfileSchema,
|
||||
token.user
|
||||
);
|
||||
|
||||
const profile: SSOProfile = {
|
||||
type: "sso",
|
||||
id,
|
||||
};
|
||||
|
||||
// Try to lookup user given their id provided in the `sub` claim.
|
||||
let user = await retrieveUserWithProfile(db, tenant.id, profile);
|
||||
if (!user) {
|
||||
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
|
||||
|
||||
// Create the new user, as one didn't exist before!
|
||||
user = await upsert(db, tenant, {
|
||||
username,
|
||||
// When the displayName is disabled on the tenant, the displayName will
|
||||
// never be set (or even stored in the database).
|
||||
displayName,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
avatar,
|
||||
profiles: [profile],
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export interface SSOVerifierOptions {
|
||||
mongo: Db;
|
||||
}
|
||||
|
||||
export class SSOVerifier {
|
||||
private mongo: Db;
|
||||
|
||||
constructor({ mongo }: SSOVerifierOptions) {
|
||||
this.mongo = mongo;
|
||||
}
|
||||
|
||||
public supports(token: SSOToken | object, tenant: Tenant): token is SSOToken {
|
||||
return tenant.auth.integrations.sso.enabled && isSSOToken(token);
|
||||
}
|
||||
|
||||
public async verify(tokenString: string, token: SSOToken, tenant: Tenant) {
|
||||
const integration = tenant.auth.integrations.sso;
|
||||
if (!integration.enabled) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("integration not enabled");
|
||||
}
|
||||
|
||||
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.
|
||||
});
|
||||
|
||||
return findOrCreateSSOUser(this.mongo, tenant, token);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,6 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
|
||||
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
|
||||
);
|
||||
|
||||
router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso"));
|
||||
router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc"));
|
||||
router.get(
|
||||
"/oidc/callback",
|
||||
|
||||
@@ -2,13 +2,13 @@ import express, { Express } from "express";
|
||||
import http from "http";
|
||||
|
||||
import config, { Config } from "talk-common/config";
|
||||
import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import getManagementSchema from "talk-server/graph/management/schema";
|
||||
import { Schemas } from "talk-server/graph/schemas";
|
||||
import getTenantSchema from "talk-server/graph/tenant/schema";
|
||||
import { createQueue } from "talk-server/services/queue";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
import { createJWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app";
|
||||
import logger from "./logger";
|
||||
import { createMongoDB } from "./services/mongodb";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`createJWTSigningConfig parses a RSA certificate 1`] = `
|
||||
"-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV
|
||||
EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+
|
||||
MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U
|
||||
C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2
|
||||
nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV
|
||||
1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS
|
||||
2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw
|
||||
fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD
|
||||
KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB
|
||||
GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7
|
||||
0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ
|
||||
kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi
|
||||
55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN
|
||||
oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v
|
||||
lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6
|
||||
9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK
|
||||
8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY
|
||||
SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1
|
||||
rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr
|
||||
xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb
|
||||
za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7
|
||||
1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0
|
||||
pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f
|
||||
RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt
|
||||
ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=
|
||||
-----END RSA PRIVATE KEY-----"
|
||||
`;
|
||||
@@ -0,0 +1,53 @@
|
||||
import sinon from "sinon";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import {
|
||||
createJWTSigningConfig,
|
||||
extractJWTFromRequest,
|
||||
} from "talk-server/services/jwt";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
describe("extractJWTFromRequest", () => {
|
||||
it("extracts the token from header", () => {
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: "Bearer token",
|
||||
},
|
||||
url: "",
|
||||
};
|
||||
|
||||
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
|
||||
|
||||
delete req.headers.authorization;
|
||||
|
||||
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
|
||||
});
|
||||
|
||||
it("extracts the token from query string", () => {
|
||||
const req = {
|
||||
url: "",
|
||||
};
|
||||
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
|
||||
|
||||
req.url = "https://talk.coralproject.net/api?access_token=token";
|
||||
|
||||
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createJWTSigningConfig", () => {
|
||||
it("parses a RSA certificate", () => {
|
||||
const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`;
|
||||
const config = {
|
||||
get: sinon.stub(),
|
||||
};
|
||||
|
||||
config.get.withArgs("signing_secret").returns(input);
|
||||
config.get.withArgs("signing_algorithm").returns("RS256");
|
||||
|
||||
const signingConfig = createJWTSigningConfig((config as any) as Config);
|
||||
|
||||
expect(signingConfig.algorithm).toEqual("RS256");
|
||||
expect(signingConfig.secret.toString()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+29
-116
@@ -1,48 +1,12 @@
|
||||
import { Redis } from "ioredis";
|
||||
import jwt, { SignOptions } from "jsonwebtoken";
|
||||
import { Db } from "mongodb";
|
||||
import { Strategy } from "passport-strategy";
|
||||
import { Bearer } from "permit";
|
||||
import uuid from "uuid";
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { retrieveUser, User } from "talk-server/models/user";
|
||||
import { User } from "talk-server/models/user";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export function extractJWTFromRequest(req: Request) {
|
||||
const permit = new Bearer({
|
||||
basic: "password",
|
||||
query: "access_token",
|
||||
});
|
||||
|
||||
return permit.check(req) || null;
|
||||
}
|
||||
|
||||
function generateJTIBlacklistKey(jti: string) {
|
||||
// jtib: JTI Blacklist namespace.
|
||||
return `jtib:${jti}`;
|
||||
}
|
||||
|
||||
export async function blacklistJWT(
|
||||
redis: Redis,
|
||||
jti: string,
|
||||
validFor: number
|
||||
) {
|
||||
await redis.setex(
|
||||
generateJTIBlacklistKey(jti),
|
||||
Math.ceil(validFor),
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkBlacklistJWT(redis: Redis, jti: string) {
|
||||
const expiredAtString = await redis.get(generateJTIBlacklistKey(jti));
|
||||
if (expiredAtString) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("JWT exists in blacklist");
|
||||
}
|
||||
}
|
||||
|
||||
export enum AsymmetricSigningAlgorithm {
|
||||
RS256 = "RS256",
|
||||
RS384 = "RS384",
|
||||
@@ -127,93 +91,42 @@ export const signTokenString = async (
|
||||
) =>
|
||||
jwt.sign({}, secret, {
|
||||
...options,
|
||||
jwtid: uuid.v4(),
|
||||
jwtid: uuid(),
|
||||
algorithm,
|
||||
expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration?
|
||||
subject: user.id,
|
||||
});
|
||||
|
||||
export interface JWTToken {
|
||||
jti: string;
|
||||
sub: string;
|
||||
exp: number;
|
||||
iss?: string;
|
||||
export function extractJWTFromRequest(req: Request) {
|
||||
const permit = new Bearer({
|
||||
basic: "password",
|
||||
query: "access_token",
|
||||
});
|
||||
|
||||
return permit.check(req) || null;
|
||||
}
|
||||
|
||||
export interface JWTStrategyOptions {
|
||||
signingConfig: JWTSigningConfig;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
function generateJTIBlacklistKey(jti: string) {
|
||||
// jtib: JTI Blacklist namespace.
|
||||
return `jtib:${jti}`;
|
||||
}
|
||||
|
||||
export class JWTStrategy extends Strategy {
|
||||
public name = "jwt";
|
||||
export async function blacklistJWT(
|
||||
redis: Redis,
|
||||
jti: string,
|
||||
validFor: number
|
||||
) {
|
||||
await redis.setex(
|
||||
generateJTIBlacklistKey(jti),
|
||||
Math.ceil(validFor),
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
private signingConfig: JWTSigningConfig;
|
||||
private mongo: Db;
|
||||
private redis: Redis;
|
||||
|
||||
constructor({ signingConfig, mongo, redis }: JWTStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.signingConfig = signingConfig;
|
||||
this.mongo = mongo;
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
public authenticate(req: Request) {
|
||||
// Lookup the token.
|
||||
const token = extractJWTFromRequest(req);
|
||||
if (!token) {
|
||||
// There was no token on the request, so there was no user, so let's mark
|
||||
// that the strategy was successful.
|
||||
return this.success(null, null);
|
||||
}
|
||||
|
||||
const { tenant } = req;
|
||||
if (!tenant) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
return this.error(new Error("tenant not found"));
|
||||
}
|
||||
|
||||
jwt.verify(
|
||||
token,
|
||||
// Use the secret specified in the configuration.
|
||||
this.signingConfig.secret,
|
||||
{
|
||||
// We need to verify that the token is for the specified tenant.
|
||||
issuer: tenant.id,
|
||||
// Use the algorithm specified in the configuration.
|
||||
algorithms: [this.signingConfig.algorithm],
|
||||
},
|
||||
async (err: Error | undefined, decoded: JWTToken) => {
|
||||
if (err) {
|
||||
return this.fail(err, 401);
|
||||
}
|
||||
|
||||
if (!decoded) {
|
||||
// There was no token on the request, so there was no user, so let's
|
||||
// mark that the strategy was successful.
|
||||
return this.success(null, null);
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the user.
|
||||
const user = await retrieveUser(this.mongo, tenant.id, decoded.sub);
|
||||
|
||||
// Check to see if the token has been blacklisted.
|
||||
await checkBlacklistJWT(this.redis, decoded.jti);
|
||||
|
||||
// Return them! The user may be null, but that's ok here.
|
||||
this.success(user, null);
|
||||
} catch (err) {
|
||||
return this.error(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
export async function checkBlacklistJWT(redis: Redis, jti: string) {
|
||||
const expiredAtString = await redis.get(generateJTIBlacklistKey(jti));
|
||||
if (expiredAtString) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("JWT exists in blacklist");
|
||||
}
|
||||
}
|
||||
|
||||
export function createJWTStrategy(options: JWTStrategyOptions) {
|
||||
return new JWTStrategy(options);
|
||||
}
|
||||
+1
-4
@@ -1,11 +1,8 @@
|
||||
import sinon from "sinon";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import {
|
||||
createJWTSigningConfig,
|
||||
extractJWTFromRequest,
|
||||
} from "talk-server/app/middleware/passport/jwt";
|
||||
import { Request } from "talk-server/types/express";
|
||||
import { createJWTSigningConfig, extractJWTFromRequest } from ".";
|
||||
|
||||
describe("extractJWTFromRequest", () => {
|
||||
it("extracts the token from header", () => {
|
||||
Vendored
+1
-1
@@ -15,5 +15,5 @@ declare module "jsonwebtoken" {
|
||||
secretOrPublicKey: string | Buffer | KeyFunction,
|
||||
options?: VerifyOptions,
|
||||
callback?: VerifyCallback
|
||||
): void;
|
||||
): object | string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user