[CORL-588] Download and token fixes (#2519)

* fix: fixed up download handler to improve error handling

* fix: fixed invalid expiry date being set

* fix: nick found bug
This commit is contained in:
Wyatt Johnson
2019-09-04 21:23:59 +00:00
committed by Kim Gardner
parent a696ef9be5
commit ac1ed45e4b
15 changed files with 442 additions and 370 deletions
@@ -1,5 +1,5 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Button, HorizontalGutter } from "coral-ui/components";
@@ -10,6 +10,11 @@ interface Props {
}
const DownloadForm: FunctionComponent<Props> = ({ token }) => {
const [submitted, setSubmitted] = useState(false);
const onClick = useCallback(() => {
setSubmitted(true);
}, [setSubmitted]);
return (
<HorizontalGutter size="double">
<form
@@ -23,7 +28,8 @@ const DownloadForm: FunctionComponent<Props> = ({ token }) => {
type="submit"
variant="filled"
color="primary"
fullWidth={false}
disabled={submitted}
onClick={onClick}
className={styles.downloadButton}
>
Download My Comment History
@@ -17,7 +17,7 @@ import styles from "./DownloadRoute.css";
const fetcher = createFetch(
"downloadToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/downloadcheck", {
await rest.fetch<void>("/account/download", {
method: "GET",
token: variables.token,
})
@@ -1,173 +1,38 @@
import archiver from "archiver";
import stringify from "csv-stringify";
import DataLoader from "dataloader";
import { Response } from "express";
import Joi from "joi";
import { kebabCase } from "lodash";
import { Db } from "mongodb";
import { AppOptions } from "coral-server/app";
import { validate } from "coral-server/app/request/body";
import { RequestLimiter } from "coral-server/app/request/limiter";
import { getLatestRevision } from "coral-server/models/comment";
import { Comment } from "coral-server/models/comment";
import { retrieveManyStories } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
import { verifyDownloadTokenString } from "coral-server/services/users/download/download";
import { RequestHandler } from "coral-server/types/express";
import { Request } from "coral-server/types/express";
const BATCH_SIZE = 100;
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
import {
redeemDownloadToken,
sendUserDownload,
verifyDownloadTokenString,
} from "coral-server/services/users/download";
import { RequestHandler } from "coral-server/types/express";
const USER_ID_LIMITER_TTL = "1d";
export type DownloadOptions = Pick<
export type AccountDownloadOptions = Pick<
AppOptions,
"mongo" | "redis" | "signingConfig" | "config"
>;
export type AdminDownloadOptions = Pick<AppOptions, "mongo" | "signingConfig">;
async function sendExport(
mongo: Db,
tenant: Tenant,
user: Readonly<User>,
latestContentDate: Date,
res: Response
) {
// Create the date formatter to format the dates for the CSV.
const formatter = Intl.DateTimeFormat(tenant.locale, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
});
// Create a DataLoader to load stories for each batch.
const getStories = new DataLoader((ids: string[]) =>
retrieveManyStories(mongo, tenant.id, ids)
);
// Create a cursor to iterate over the user's comment's in order.
const cursor = mongo
.collection<Readonly<Comment>>("comments")
.find({
tenantID: tenant.id,
authorID: user.id,
createdAt: {
$lt: latestContentDate,
},
})
.sort({ createdAt: 1 });
// Collect all the user's comments in batches.
let commentBatch: Array<Readonly<Comment>> = [];
// Generate the filename of the file that the user will download.
const filename = `talk-${kebabCase(user.username)}-${kebabCase(
formatter.format(latestContentDate)
)}.zip`;
res.writeHead(200, {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename=${filename}`,
});
// Create the zip archive we'll use to write all the exported files to.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Pipe this to the response writer directly.
archive.pipe(res);
// Create all the csv writers that'll write the data to the archive.
const csv = stringify();
// Add all the streams as files to the archive.
archive.append(csv, { name: "comments-export/my_comments.csv" });
csv.write([
"Comment ID",
"Published Timestamp",
"Article URL",
"Comment URL",
"Comment Text",
]);
/**
* writeAndFlushBatch will write the given batch of comments to the CSV and
* flush out the batchfor the next run.
*/
const writeAndFlushBatch = async () => {
const stories = await getStories.loadMany(
commentBatch.map(({ storyID }) => storyID)
);
for (let i = 0; i < commentBatch.length; i++) {
const comment = commentBatch[i];
const story = stories[i];
if (!story) {
continue;
}
const revision = getLatestRevision(comment);
const commentID = comment.id;
const createdAt = formatter.format(new Date(comment.createdAt));
const storyURL = story.url;
const commentURL = `${storyURL}?commentID=${commentID}`;
const body = revision.body;
csv.write([commentID, createdAt, storyURL, commentURL, body]);
}
commentBatch = [];
};
while (await cursor.hasNext()) {
const comment = await cursor.next();
if (!comment) {
break;
}
commentBatch.push(comment);
if (commentBatch.length >= BATCH_SIZE) {
await writeAndFlushBatch();
}
}
if (commentBatch.length > 0) {
await writeAndFlushBatch();
}
csv.end();
// Mark the end of adding files, no more files can be added after this. Once
// all the stream readers have finished writing, and have closed, the
// archiver will close which will finish the HTTP request.
await archive.finalize();
}
export interface DownloadBody {
export interface AccountDownloadBody {
token: string;
}
export const DownloadBodySchema = Joi.object().keys({
export const AccountDownloadBodySchema = Joi.object().keys({
token: Joi.string().trim(),
});
export const downloadHandler = ({
export const accountDownloadHandler = ({
mongo,
redis,
signingConfig,
config,
}: DownloadOptions): RequestHandler => {
}: AccountDownloadOptions): RequestHandler => {
const userIDLimiter = new RequestLimiter({
redis,
ttl: USER_ID_LIMITER_TTL,
@@ -176,29 +41,33 @@ export const downloadHandler = ({
config,
});
return async (req: Request, res, next) => {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { token }: DownloadBody = validate(DownloadBodySchema, req.body);
// Decode the token so we can rate limit based on the user's ID.
const { sub: userID } = decodeJWT(token);
if (!userID) {
return res.sendStatus(400);
}
await userIDLimiter.test(req, userID);
return async (req, res, next) => {
try {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { token }: AccountDownloadBody = validate(
AccountDownloadBodySchema,
req.body
);
// Decode the token so we can rate limit based on the user's ID.
const { sub: userID } = decodeJWT(token);
if (!userID) {
return res.sendStatus(400);
}
await userIDLimiter.test(req, userID);
const {
token: { iat },
user,
} = await verifyDownloadTokenString(
} = await redeemDownloadToken(
mongo,
redis,
tenant,
signingConfig,
token,
@@ -209,7 +78,7 @@ export const downloadHandler = ({
const latestContentDate = new Date(iat * 1000);
// Send the export down the response.
await sendExport(mongo, tenant, user, latestContentDate, res);
await sendUserDownload(res, mongo, tenant, user, latestContentDate);
return;
} catch (err) {
@@ -218,61 +87,21 @@ export const downloadHandler = ({
};
};
export const adminDownloadHandler = ({
mongo,
signingConfig,
}: AdminDownloadOptions): RequestHandler => {
return async (req: Request, res, next) => {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { token } = req.query;
const { sub: userID } = decodeJWT(token);
if (!userID) {
return res.sendStatus(400);
}
try {
const {
token: { iat },
user,
} = await verifyDownloadTokenString(
mongo,
tenant,
signingConfig,
token,
coral.now
);
// Only load comments since this download token was issued.
const latestContentDate = new Date(iat * 1000);
// Send the export down the response.
await sendExport(mongo, tenant, user, latestContentDate, res);
return;
} catch (err) {
return next(err);
}
};
};
export type DownloadCheckOptions = Pick<
export type AccountDownloadCheckOptions = Pick<
AppOptions,
"mongo" | "redis" | "signingConfig" | "config"
>;
export const downloadCheckHandler = ({
export const accountDownloadCheckHandler = ({
mongo,
redis,
signingConfig,
config,
}: DownloadCheckOptions): RequestHandler => {
}: AccountDownloadCheckOptions): RequestHandler => {
const userIDLimiter = new RequestLimiter({
redis,
ttl: USER_ID_LIMITER_TTL,
max: 1,
ttl: "10m",
max: 10,
prefix: "userID",
config,
});
@@ -297,6 +126,7 @@ export const downloadCheckHandler = ({
await verifyDownloadTokenString(
mongo,
redis,
tenant,
signingConfig,
tokenString,
@@ -4,3 +4,4 @@ export * from "./graphql";
export * from "./health";
export * from "./install";
export * from "./version";
export * from "./user";
@@ -0,0 +1,54 @@
import { AppOptions } from "coral-server/app";
import { decodeJWT } from "coral-server/services/jwt";
import {
redeemDownloadToken,
sendUserDownload,
} from "coral-server/services/users/download";
import { RequestHandler } from "coral-server/types/express";
type AdminDownloadOptions = Pick<
AppOptions,
"mongo" | "redis" | "signingConfig"
>;
export const userDownloadHandler = ({
mongo,
redis,
signingConfig,
}: AdminDownloadOptions): RequestHandler => {
return async (req, res, next) => {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { token } = req.query;
const { sub: userID } = decodeJWT(token);
if (!userID) {
return res.sendStatus(400);
}
try {
const {
token: { iat },
user,
} = await redeemDownloadToken(
mongo,
redis,
tenant,
signingConfig,
token,
coral.now
);
// Only load comments since this download token was issued.
const latestContentDate = new Date(iat * 1000);
// Send the export down the response.
await sendUserDownload(res, mongo, tenant, user, latestContentDate);
return;
} catch (err) {
return next(err);
}
};
};
@@ -0,0 +1 @@
export * from "./download";
@@ -91,17 +91,9 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
// Grab the JTI from the decoded token.
const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded);
if (jti && exp) {
// Compute the number of seconds that the token will be valid for.
const validFor = Math.round(
DateTime.fromJSDate(now)
.plus({ seconds: -exp })
.toSeconds()
);
if (validFor > 0) {
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.
await revokeJWT(redis, jti, validFor, now);
}
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.
await revokeJWT(redis, jti, exp, now);
}
// Clear the cookie.
@@ -132,9 +124,7 @@ export async function handleSuccessfulLogin(
signingConfig,
user,
tenant,
{
expiresIn: Math.round(expiresIn.toSeconds()),
},
{ expiresIn: "1d" },
coral.now
);
@@ -194,9 +184,7 @@ export async function handleOAuth2Callback(
signingConfig,
user,
tenant,
{
expiresIn: Math.round(expiresIn.toSeconds()),
},
{ expiresIn: "1d" },
req.coral!.now
);
res.cookie(
+4 -6
View File
@@ -3,12 +3,11 @@ import express from "express";
import { AppOptions } from "coral-server/app";
import {
adminDownloadHandler,
accountDownloadCheckHandler,
accountDownloadHandler,
confirmCheckHandler,
confirmHandler,
confirmRequestHandler,
downloadCheckHandler,
downloadHandler,
inviteCheckHandler,
inviteHandler,
} from "coral-server/app/handlers";
@@ -34,14 +33,13 @@ export function createNewAccountRouter(
router.get("/invite", inviteCheckHandler(app));
router.put("/invite", jsonMiddleware, inviteHandler(app));
router.get("/downloadcheck", downloadCheckHandler(app));
router.get("/download", adminDownloadHandler(app));
router.get("/download", accountDownloadCheckHandler(app));
router.post(
"/download",
bodyParser.urlencoded({
extended: true,
}),
downloadHandler(app)
accountDownloadHandler(app)
);
return router;
+2
View File
@@ -18,6 +18,7 @@ import { tenantMiddleware } from "coral-server/app/middleware/tenant";
import { createNewAccountRouter } from "./account";
import { createNewAuthRouter } from "./auth";
import { createNewUserRouter } from "./user";
export interface RouterOptions {
/**
@@ -55,6 +56,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Create the auth router.
router.use("/auth", createNewAuthRouter(app, options));
router.use("/account", createNewAccountRouter(app, options));
router.use("/user", createNewUserRouter(app));
// Configure the GraphQL route.
router.use(
+12
View File
@@ -0,0 +1,12 @@
import express from "express";
import { AppOptions } from "coral-server/app";
import { userDownloadHandler } from "coral-server/app/handlers";
export function createNewUserRouter(app: AppOptions) {
const router = express.Router();
router.get("/download", userDownloadHandler(app));
return router;
}
+12 -6
View File
@@ -354,20 +354,26 @@ function generateJTIRevokedKey(jti: string) {
*
* @param redis the Redis instance to revoke the JWT with
* @param jti the JTI claim of the JWT token being revoked
* @param validFor number of seconds that the token was valid for
* @param exp time that the token expired at
* @param now the current date
*/
export async function revokeJWT(
redis: Redis,
jti: string,
validFor: number,
exp: number,
now = new Date()
) {
await redis.setex(
generateJTIRevokedKey(jti),
Math.round(validFor),
Math.round(DateTime.fromJSDate(now).toSeconds())
const validFor = Math.round(
DateTime.fromSeconds(exp).diff(DateTime.fromJSDate(now), "seconds").seconds
);
if (validFor > 0) {
await redis.setex(
generateJTIRevokedKey(jti),
validFor,
Math.round(DateTime.fromJSDate(now).toSeconds())
);
}
}
/**
@@ -1,142 +1,139 @@
import Joi from "joi";
import { isNull } from "lodash";
import { DateTime } from "luxon";
import archiver from "archiver";
import stringify from "csv-stringify";
import DataLoader from "dataloader";
import { Response } from "express";
import { kebabCase } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
import { constructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
import { TokenInvalidError, UserNotFoundError } from "coral-server/errors";
import { getLatestRevision } from "coral-server/models/comment";
import { Comment } from "coral-server/models/comment";
import { retrieveManyStories } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUser } from "coral-server/models/user";
import {
JWTSigningConfig,
signString,
StandardClaims,
StandardClaimsSchema,
verifyJWT,
} from "coral-server/services/jwt";
import { User } from "coral-server/models/user";
interface DownloadToken extends Required<StandardClaims> {
aud: "download";
}
const BATCH_SIZE = 100;
const DownloadTokenSchema = StandardClaimsSchema.keys({
aud: Joi.string().only("download"),
});
export async function generateDownloadToken(
userID: string,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
now: Date
) {
const nowDate = DateTime.fromJSDate(now);
const nowSeconds = Math.round(nowDate.toSeconds());
const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds());
const downloadToken: DownloadToken = {
jti: uuid.v4(),
iss: tenant.id,
sub: userID,
exp: expiresAt,
iat: nowSeconds,
nbf: nowSeconds,
aud: "download",
};
return await signString(signingConfig, downloadToken);
}
export async function generateDownloadLink(
userID: string,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
now: Date
) {
const token = await generateDownloadToken(
userID,
tenant,
config,
signingConfig,
now
);
return constructTenantURL(
config,
tenant,
`/account/download#downloadToken=${token}`
);
}
export async function generateAdminDownloadLink(
userID: string,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
now: Date
) {
const token = await generateDownloadToken(
userID,
tenant,
config,
signingConfig,
now
);
return constructTenantURL(
config,
tenant,
`/api/account/download?token=${token}`
);
}
export function validateDownloadToken(
token: DownloadToken | object
): Error | null {
const { error } = Joi.validate(token, DownloadTokenSchema, {
presence: "required",
});
return error || null;
}
export function isDownloadToken(
token: DownloadToken | object
): token is DownloadToken {
return isNull(validateDownloadToken(token));
}
export async function verifyDownloadTokenString(
export async function sendUserDownload(
res: Response,
mongo: Db,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
user: Readonly<User>,
latestContentDate: Date
) {
const token = verifyJWT(tokenString, signingConfig, now, {
issuer: tenant.id,
audience: "download",
// Create the date formatter to format the dates for the CSV.
const formatter = Intl.DateTimeFormat(tenant.locale, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
});
if (!isDownloadToken(token)) {
throw new TokenInvalidError(
tokenString,
"does not conform to the download token schema"
// Create a DataLoader to load stories for each batch.
const getStories = new DataLoader((ids: string[]) =>
retrieveManyStories(mongo, tenant.id, ids)
);
// Create a cursor to iterate over the user's comment's in order.
const cursor = mongo
.collection<Readonly<Comment>>("comments")
.find({
tenantID: tenant.id,
authorID: user.id,
createdAt: {
$lt: latestContentDate,
},
})
.sort({ createdAt: 1 });
// Collect all the user's comments in batches.
let commentBatch: Array<Readonly<Comment>> = [];
// Generate the filename of the file that the user will download.
const filename = `talk-${kebabCase(user.username)}-${kebabCase(
formatter.format(latestContentDate)
)}.zip`;
res.writeHead(200, {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename=${filename}`,
});
// Create the zip archive we'll use to write all the exported files to.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Pipe this to the response writer directly.
archive.pipe(res);
// Create all the csv writers that'll write the data to the archive.
const csv = stringify();
// Add all the streams as files to the archive.
archive.append(csv, { name: "comments-export/my_comments.csv" });
csv.write([
"Comment ID",
"Published Timestamp",
"Article URL",
"Comment URL",
"Comment Text",
]);
/**
* writeAndFlushBatch will write the given batch of comments to the CSV and
* flush out the batchfor the next run.
*/
const writeAndFlushBatch = async () => {
const stories = await getStories.loadMany(
commentBatch.map(({ storyID }) => storyID)
);
for (let i = 0; i < commentBatch.length; i++) {
const comment = commentBatch[i];
const story = stories[i];
if (!story) {
continue;
}
const revision = getLatestRevision(comment);
const commentID = comment.id;
const createdAt = formatter.format(new Date(comment.createdAt));
const storyURL = story.url;
const commentURL = `${storyURL}?commentID=${commentID}`;
const body = revision.body;
csv.write([commentID, createdAt, storyURL, commentURL, body]);
}
commentBatch = [];
};
while (await cursor.hasNext()) {
const comment = await cursor.next();
if (!comment) {
break;
}
commentBatch.push(comment);
if (commentBatch.length >= BATCH_SIZE) {
await writeAndFlushBatch();
}
}
const { sub: userID, iss } = token;
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
if (commentBatch.length > 0) {
await writeAndFlushBatch();
}
if (iss !== tenant.id) {
throw new TokenInvalidError(tokenString, "invalid tenant");
}
csv.end();
return { token, user };
// Mark the end of adding files, no more files can be added after this. Once
// all the stream readers have finished writing, and have closed, the
// archiver will close which will finish the HTTP request.
await archive.finalize();
}
@@ -0,0 +1,2 @@
export * from "./download";
export * from "./token";
@@ -0,0 +1,175 @@
import { Redis } from "ioredis";
import Joi from "joi";
import { isNull } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import uuid from "uuid";
import { constructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
import { TokenInvalidError, UserNotFoundError } from "coral-server/errors";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUser } from "coral-server/models/user";
import {
isJWTRevoked,
JWTSigningConfig,
revokeJWT,
signString,
StandardClaims,
StandardClaimsSchema,
verifyJWT,
} from "coral-server/services/jwt";
interface DownloadToken extends Required<StandardClaims> {
aud: "download";
}
const DownloadTokenSchema = StandardClaimsSchema.keys({
aud: Joi.string().only("download"),
});
export async function generateDownloadToken(
userID: string,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
now: Date
) {
const nowDate = DateTime.fromJSDate(now);
const nowSeconds = Math.round(nowDate.toSeconds());
const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds());
const downloadToken: DownloadToken = {
jti: uuid.v4(),
iss: tenant.id,
sub: userID,
exp: expiresAt,
iat: nowSeconds,
nbf: nowSeconds,
aud: "download",
};
return await signString(signingConfig, downloadToken);
}
export async function generateDownloadLink(
userID: string,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
now: Date
) {
const token = await generateDownloadToken(
userID,
tenant,
config,
signingConfig,
now
);
return constructTenantURL(
config,
tenant,
`/account/download#downloadToken=${token}`
);
}
export async function generateAdminDownloadLink(
userID: string,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
now: Date
) {
const token = await generateDownloadToken(
userID,
tenant,
config,
signingConfig,
now
);
return constructTenantURL(
config,
tenant,
`/api/user/download?token=${token}`
);
}
export function validateDownloadToken(
token: DownloadToken | object
): Error | null {
const { error } = Joi.validate(token, DownloadTokenSchema, {
presence: "required",
});
return error || null;
}
export function isDownloadToken(
token: DownloadToken | object
): token is DownloadToken {
return isNull(validateDownloadToken(token));
}
export async function verifyDownloadTokenString(
mongo: Db,
redis: Redis,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
) {
const token = verifyJWT(tokenString, signingConfig, now, {
issuer: tenant.id,
audience: "download",
});
if (!isDownloadToken(token)) {
throw new TokenInvalidError(
tokenString,
"does not conform to the download token schema"
);
}
const { sub: userID, iss } = token;
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
if (iss !== tenant.id) {
throw new TokenInvalidError(tokenString, "invalid tenant");
}
// Check to see if the token was revoked.
if (await isJWTRevoked(redis, token.jti)) {
throw new TokenInvalidError(tokenString, "token was revoked");
}
return { token, user };
}
export async function redeemDownloadToken(
mongo: Db,
redis: Redis,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
) {
// Verify the download token.
const { token, user } = await verifyDownloadTokenString(
mongo,
redis,
tenant,
signingConfig,
tokenString,
now
);
// Revoke the download token.
await revokeJWT(redis, token.jti, token.exp, now);
return { token, user };
}
+1 -1
View File
@@ -71,7 +71,7 @@ import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
import {
generateAdminDownloadLink,
generateDownloadLink,
} from "./download/download";
} from "./download/token";
import { validateEmail, validatePassword, validateUsername } from "./helpers";
export type InsertUser = InsertUserInput;