mirror of
https://github.com/wassname/talk.git
synced 2026-07-23 13:10:20 +08:00
feat: initial token issuing support
This commit is contained in:
@@ -2,7 +2,8 @@ import { RequestHandler } from "express";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { handle } from "talk-server/app/middleware/passport";
|
||||
import { 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";
|
||||
@@ -29,9 +30,10 @@ const SignupDisplayNameBodySchema = SignupBodySchema.keys({
|
||||
|
||||
export interface SignupOptions {
|
||||
db: Db;
|
||||
signingConfig: JWTSigningConfig;
|
||||
}
|
||||
|
||||
export const signupHandler = ({ db }: SignupOptions): RequestHandler => async (
|
||||
export const signupHandler = (options: SignupOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res,
|
||||
next
|
||||
@@ -67,7 +69,7 @@ export const signupHandler = ({ db }: SignupOptions): RequestHandler => async (
|
||||
};
|
||||
|
||||
// Create the new user.
|
||||
const user = await upsert(db, tenant, {
|
||||
const user = await upsert(options.db, tenant, {
|
||||
email,
|
||||
username,
|
||||
displayName,
|
||||
@@ -79,8 +81,8 @@ export const signupHandler = ({ db }: SignupOptions): RequestHandler => async (
|
||||
});
|
||||
|
||||
// Send off to the passport handler.
|
||||
return handle(null, user)(req, res, next);
|
||||
return handleSuccessfulLogin(user, options.signingConfig, req, res, next);
|
||||
} catch (err) {
|
||||
return handle(err)(req, res, next);
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { NextFunction, RequestHandler, Response } from "express";
|
||||
import { Db } from "mongodb";
|
||||
import passport, { Authenticator } from "passport";
|
||||
|
||||
import {
|
||||
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 { User } from "talk-server/models/user";
|
||||
@@ -32,38 +37,69 @@ export function createPassport({
|
||||
return auth;
|
||||
}
|
||||
|
||||
export const handle = (
|
||||
err: Error | null,
|
||||
user?: User | null
|
||||
): RequestHandler => (req: Request, res, next) => {
|
||||
if (err) {
|
||||
// TODO: wrap error?
|
||||
export async function handleSuccessfulLogin(
|
||||
user: User,
|
||||
signingConfig: JWTSigningConfig,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Grab the tenant from the request.
|
||||
const { tenant } = req;
|
||||
|
||||
const options: SigningTokenOptions = {};
|
||||
|
||||
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.
|
||||
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.json({ token });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// TODO: replace with better error.
|
||||
return next(new Error("no user on request"));
|
||||
}
|
||||
|
||||
// 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!
|
||||
|
||||
// TODO: return the token instead of the user.
|
||||
res.json({ user });
|
||||
};
|
||||
|
||||
/**
|
||||
* authenticate will wrap a authenticators authenticate method with one that
|
||||
* will return a valid login token 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 authenticate = (
|
||||
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) => handle(err, user)(req, res, next)
|
||||
(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"));
|
||||
}
|
||||
|
||||
handleSuccessfulLogin(user, signingConfig, req, res, next);
|
||||
}
|
||||
)(req, res, next);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import jwt, { SignOptions } from "jsonwebtoken";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Config } from "talk-server/config";
|
||||
import { User } from "talk-server/models/user";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
const re = /(\S+)\s+(\S+)/;
|
||||
const authHeaderRegex = /(\S+)\s+(\S+)/;
|
||||
|
||||
export function parseAuthHeader(header: string) {
|
||||
const matches = header.match(re);
|
||||
const matches = header.match(authHeaderRegex);
|
||||
if (!matches || matches.length < 3) {
|
||||
return null;
|
||||
}
|
||||
@@ -30,3 +35,94 @@ export function extractJWTFromRequest(req: Request) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export enum AsymmetricSigningAlgorithm {
|
||||
RS256 = "RS256",
|
||||
RS384 = "RS384",
|
||||
RS512 = "RS512",
|
||||
ES256 = "ES256",
|
||||
ES384 = "ES384",
|
||||
ES512 = "ES512",
|
||||
}
|
||||
|
||||
export enum SymmetricSigningAlgorithm {
|
||||
HS256 = "HS256",
|
||||
HS384 = "HS384",
|
||||
HS512 = "HS512",
|
||||
}
|
||||
|
||||
export type JWTSigningAlgorithm =
|
||||
| AsymmetricSigningAlgorithm
|
||||
| SymmetricSigningAlgorithm;
|
||||
|
||||
export interface JWTSigningConfig {
|
||||
secret: Buffer;
|
||||
algorithm: JWTSigningAlgorithm;
|
||||
}
|
||||
|
||||
export function createAsymmetricSigningConfig(
|
||||
algorithm: AsymmetricSigningAlgorithm,
|
||||
secret: string
|
||||
): JWTSigningConfig {
|
||||
return {
|
||||
// Secrets have their newlines encoded with newline litterals.
|
||||
secret: Buffer.from(secret.replace(/\\n/g, "\n")),
|
||||
algorithm,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSymmetricSigningConfig(
|
||||
algorithm: SymmetricSigningAlgorithm,
|
||||
secret: string
|
||||
): JWTSigningConfig {
|
||||
return {
|
||||
secret: new Buffer(secret),
|
||||
algorithm,
|
||||
};
|
||||
}
|
||||
|
||||
function isSymmetricSigningAlgorithm(
|
||||
algorithm: string | SymmetricSigningAlgorithm
|
||||
): algorithm is SymmetricSigningAlgorithm {
|
||||
return algorithm in SymmetricSigningAlgorithm;
|
||||
}
|
||||
|
||||
function isAsymmetricSigningAlgorithm(
|
||||
algorithm: string | AsymmetricSigningAlgorithm
|
||||
): algorithm is AsymmetricSigningAlgorithm {
|
||||
return algorithm in AsymmetricSigningAlgorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the config and provides the signing config.
|
||||
*
|
||||
* @param config the server configuration
|
||||
*/
|
||||
export function createJWTSigningConfig(config: Config): JWTSigningConfig {
|
||||
const secret = config.get("signing_secret");
|
||||
const algorithm = config.get("signing_algorithm");
|
||||
if (isSymmetricSigningAlgorithm(algorithm)) {
|
||||
return createSymmetricSigningConfig(algorithm, secret);
|
||||
} else if (isAsymmetricSigningAlgorithm(algorithm)) {
|
||||
return createAsymmetricSigningConfig(algorithm, secret);
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("invalid algorithm specified");
|
||||
}
|
||||
|
||||
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
|
||||
|
||||
export async function signTokenString(
|
||||
{ algorithm, secret }: JWTSigningConfig,
|
||||
user: User,
|
||||
options: SigningTokenOptions
|
||||
) {
|
||||
return jwt.sign({}, secret, {
|
||||
...options,
|
||||
jwtid: uuid.v4(),
|
||||
algorithm,
|
||||
expiresIn: "1 day", // TODO: (wyattjoh) evalue allowing configuration?
|
||||
subject: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import tenantMiddleware from "talk-server/app/middleware/tenant";
|
||||
import managementGraphMiddleware from "talk-server/graph/management/middleware";
|
||||
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
|
||||
|
||||
import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import { AppOptions } from "./index";
|
||||
import playground from "./middleware/playground";
|
||||
|
||||
@@ -54,23 +55,26 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) {
|
||||
function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
|
||||
const router = express.Router();
|
||||
|
||||
// Create the signing config.
|
||||
const signingConfig = createJWTSigningConfig(app.config);
|
||||
|
||||
// Mount the passport routes.
|
||||
router.post(
|
||||
"/local",
|
||||
express.json(),
|
||||
authenticate(options.passport, "local")
|
||||
authenticate(options.passport, signingConfig, "local")
|
||||
);
|
||||
router.post(
|
||||
"/local/signup",
|
||||
express.json(),
|
||||
signupHandler({ db: app.mongo })
|
||||
signupHandler({ db: app.mongo, signingConfig })
|
||||
);
|
||||
router.post("/sso", authenticate(options.passport, signingConfig, "sso"));
|
||||
router.get("/oidc", authenticate(options.passport, signingConfig, "oidc"));
|
||||
router.get(
|
||||
"/oidc/callback",
|
||||
authenticate(options.passport, signingConfig, "oidc")
|
||||
);
|
||||
router.post("/sso", authenticate(options.passport, "sso"));
|
||||
router.get("/oidc", authenticate(options.passport, "oidc"));
|
||||
router.get("/oidc/callback", authenticate(options.passport, "oidc"));
|
||||
// router.get("/google", options.passport.authenticate("google"));
|
||||
// router.get("/google/callback", options.passport.authenticate("google"));
|
||||
// router.get("/facebook", options.passport.authenticate("facebook"));
|
||||
// router.get("/facebook/callback", options.passport.authenticate("facebook"));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -55,12 +55,29 @@ const config = convict({
|
||||
env: "REDIS",
|
||||
arg: "redis",
|
||||
},
|
||||
secret: {
|
||||
doc: "The secret used to sign and verify JWTs",
|
||||
signing_secret: {
|
||||
doc: "",
|
||||
format: "*",
|
||||
default: null,
|
||||
env: "SECRET",
|
||||
arg: "secret",
|
||||
default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution
|
||||
env: "SIGNING_SECRET",
|
||||
arg: "signingSecret",
|
||||
},
|
||||
signing_algorithm: {
|
||||
doc: "",
|
||||
format: [
|
||||
"HS256",
|
||||
"HS384",
|
||||
"HS512",
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
],
|
||||
default: "HS256",
|
||||
env: "SIGNING_ALGORITHM",
|
||||
arg: "signingAlgorithm",
|
||||
},
|
||||
logging_level: {
|
||||
doc: "The logging level to print to the console",
|
||||
|
||||
Reference in New Issue
Block a user