feat(api): port /confirm-email to new api (#54975)

Co-authored-by: Niraj Nandish <nirajnandish@icloud.com>
This commit is contained in:
Oliver Eyton-Williams
2024-06-27 10:07:53 +02:00
committed by GitHub
parent 2c611fb15b
commit 22e74e6406
11 changed files with 633 additions and 64 deletions

View File

@@ -46,10 +46,11 @@ export function superRequest(
config: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
setCookies?: string[];
headers?: { referer: string };
},
options?: Options
): request.Test {
const { method, setCookies } = config;
const { method, setCookies, headers } = config;
const { sendCSRFToken = true } = options ?? {};
const req = requests[method](resource).set('Origin', ORIGIN);
@@ -58,6 +59,10 @@ export function superRequest(
void req.set('Cookie', getCookies(setCookies));
}
if (headers) {
void req.set('Referer', headers.referer);
}
const csrfToken = (setCookies && getCsrfToken(setCookies)) ?? '';
if (sendCSRFToken) {
void req.set('CSRF-Token', csrfToken);

View File

@@ -36,7 +36,7 @@ import { deprecatedEndpoints } from './routes/deprecated-endpoints';
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
import { donateRoutes } from './routes/donate';
import { emailSubscribtionRoutes } from './routes/email-subscription';
import { settingRoutes } from './routes/settings';
import { settingRoutes, settingRedirectRoutes } from './routes/settings';
import { statusRoute } from './routes/status';
import { userGetRoutes, userRoutes, userPublicGetRoutes } from './routes/user';
import {
@@ -180,6 +180,8 @@ export const build = async (
fastify.log.info(`Swagger UI available at ${API_LOCATION}/documentation`);
}
// redirectWithMessage must be registered before codeFlowAuth
void fastify.register(redirectWithMessage);
void fastify.register(codeFlowAuth);
void fastify.register(prismaPlugin);
void fastify.register(mobileAuth0Routes);
@@ -188,6 +190,7 @@ export const build = async (
}
void fastify.register(challengeRoutes);
void fastify.register(settingRoutes);
void fastify.register(settingRedirectRoutes);
void fastify.register(donateRoutes);
void fastify.register(emailSubscribtionRoutes);
void fastify.register(userRoutes);
@@ -198,7 +201,6 @@ export const build = async (
void fastify.register(deprecatedEndpoints);
void fastify.register(statusRoute);
void fastify.register(unsubscribeDeprecated);
void fastify.register(redirectWithMessage);
return fastify;
};

View File

@@ -5,6 +5,7 @@ import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
import { type Token, createAccessToken } from '../utils/tokens';
import cookies, { sign as signCookie, unsign as unsignCookie } from './cookies';
import codeFlowAuth from './code-flow-auth';
import redirectWithMessage, { formatMessage } from './redirect-with-message';
describe('auth', () => {
let fastify: FastifyInstance;
@@ -12,6 +13,7 @@ describe('auth', () => {
beforeEach(async () => {
fastify = Fastify();
await fastify.register(cookies);
await fastify.register(redirectWithMessage);
await fastify.register(codeFlowAuth);
});
@@ -203,4 +205,124 @@ describe('auth', () => {
expect(res.json()).toEqual({ ok: true });
});
});
describe('authorizeOrRedirect', () => {
const redirectLocation = `http://localhost:8000?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
beforeEach(() => {
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
fastify.get('/test', () => {
return { message: 'ok' };
});
});
it('should redirect to the origin if the access token is missing', async () => {
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.headers.location).toBe(redirectLocation);
expect(res.statusCode).toBe(302);
});
it('should redirect to the origin if the access token is not signed', async () => {
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: token
}
});
expect(res.headers.location).toBe(redirectLocation);
expect(res.statusCode).toBe(302);
});
it('should redirect to the origin if the access token is invalid', async () => {
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
'invalid-secret'
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.headers.location).toBe(redirectLocation);
expect(res.statusCode).toBe(302);
});
it('should redirect to the origin if the access token has expired', async () => {
const token = jwt.sign(
{ accessToken: createAccessToken('123', -1) },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.headers.location).toBe(redirectLocation);
expect(res.statusCode).toBe(302);
});
it('should redirect to the origin if the user is not found', async () => {
// @ts-expect-error prisma isn't defined, since we're not building the
// full application here.
fastify.prisma = { user: { findUnique: () => null } };
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.headers.location).toBe(redirectLocation);
expect(res.statusCode).toBe(302);
});
it('should populate the request with the user if the token is valid', async () => {
const fakeUser = { id: '123', username: 'test-user' };
// @ts-expect-error prisma isn't defined, since we're not building the
// full application here.
fastify.prisma = { user: { findUnique: () => fakeUser } };
fastify.get('/test-user', req => {
expect(req.user).toEqual(fakeUser);
return { ok: true };
});
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test-user',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.json()).toEqual({ ok: true });
});
});
});

View File

@@ -1,11 +1,11 @@
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import jwt from 'jsonwebtoken';
import { isBefore } from 'date-fns';
import { type user } from '@prisma/client';
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
import { type Token } from '../utils/tokens';
import { type Token, isExpired } from '../utils/tokens';
import { getRedirectParams } from '../utils/redirection';
declare module 'fastify' {
interface FastifyReply {
@@ -19,6 +19,7 @@ declare module 'fastify' {
interface FastifyInstance {
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
authorizeOrRedirect: (req: FastifyRequest, reply: FastifyReply) => void;
}
}
@@ -40,41 +41,70 @@ const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
const TOKEN_INVALID = 'Your access token is invalid';
const TOKEN_EXPIRED = 'Access token is no longer valid';
const send401 = (reply: FastifyReply, message: string) =>
reply.status(401).send({ type: 'info', message });
const send401 = (
_req: FastifyRequest,
reply: FastifyReply,
message: string
): void => {
void reply.status(401).send({ type: 'info', message });
};
fastify.decorate(
'authorize',
async function (req: FastifyRequest, reply: FastifyReply) {
const redirectHome = (
req: FastifyRequest,
reply: FastifyReply,
_ignored: string
) => {
const { origin } = getRedirectParams(req);
void reply.redirectWithMessage(origin, {
type: 'info',
content:
'Only authenticated users can access this route. Please sign in and try again.'
});
};
const handleAuth =
(
rejectStrategy: (
req: FastifyRequest,
reply: FastifyReply,
message: string
) => void
) =>
async (req: FastifyRequest, reply: FastifyReply) => {
const tokenCookie = req.cookies.jwt_access_token;
if (!tokenCookie) return send401(reply, TOKEN_REQUIRED);
if (!tokenCookie) return rejectStrategy(req, reply, TOKEN_REQUIRED);
const unsignedToken = req.unsignCookie(tokenCookie);
if (!unsignedToken.valid) return send401(reply, TOKEN_REQUIRED);
if (!unsignedToken.valid)
return rejectStrategy(req, reply, TOKEN_REQUIRED);
const jwtAccessToken = unsignedToken.value;
try {
jwt.verify(jwtAccessToken!, JWT_SECRET);
} catch {
return send401(reply, TOKEN_INVALID);
return rejectStrategy(req, reply, TOKEN_INVALID);
}
const {
accessToken: { created, ttl, userId }
} = jwt.decode(jwtAccessToken!) as { accessToken: Token };
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
if (!valid) return send401(reply, TOKEN_EXPIRED);
const { accessToken } = jwt.decode(jwtAccessToken!) as {
accessToken: Token;
};
if (isExpired(accessToken))
return rejectStrategy(req, reply, TOKEN_EXPIRED);
const user = await fastify.prisma.user.findUnique({
where: { id: userId }
where: { id: accessToken.userId }
});
if (!user) return send401(reply, TOKEN_INVALID);
if (!user) return rejectStrategy(req, reply, TOKEN_INVALID);
req.user = user;
}
);
};
fastify.decorate('authorize', handleAuth(send401));
fastify.decorate('authorizeOrRedirect', handleAuth(redirectHome));
done();
};
export default fp(codeFlowAuth);
export default fp(codeFlowAuth, { dependencies: ['redirect-with-message'] });

View File

@@ -32,15 +32,22 @@ function redirectWithMessage(
url: string,
message: Message
) {
return this.redirect(
`${url}?${qs.stringify(
{
messages: qs.stringify(prepareMessage(message), {
arrayFormat: 'index'
})
},
{ arrayFormat: 'index' }
)}`
return this.redirect(`${url}?${formatMessage(message)}`);
}
/**
* Formats the message into a querystring.
* @param message The message to format.
* @returns The formatted message string.
*/
export function formatMessage(message: Message): string {
return qs.stringify(
{
messages: qs.stringify(prepareMessage(message), {
arrayFormat: 'index'
})
},
{ arrayFormat: 'index' }
);
}
@@ -50,4 +57,4 @@ const plugin: FastifyPluginCallback = (fastify, _options, done) => {
done();
};
export default fp(plugin);
export default fp(plugin, { name: 'redirect-with-message' });

View File

@@ -1,13 +1,16 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import {
devLogin,
setupServer,
superRequest,
createSuperRequest,
defaultUserId
defaultUserId,
defaultUserEmail
} from '../../jest.utils';
import { formatMessage } from '../plugins/redirect-with-message';
import { createUserInput } from '../utils/create-user';
import { API_LOCATION } from '../utils/env';
import { API_LOCATION, HOME_LOCATION } from '../utils/env';
import { isPictureWithProtocol, getWaitMessage } from './settings';
const baseProfileUI = {
@@ -98,11 +101,13 @@ describe('settingRoutes', () => {
describe('Authenticated user', () => {
let superPut: ReturnType<typeof createSuperRequest>;
let superGet: ReturnType<typeof createSuperRequest>;
// Authenticate user
beforeAll(async () => {
const setCookies = await devLogin();
superPut = createSuperRequest({ method: 'PUT', setCookies });
superGet = createSuperRequest({ method: 'GET', setCookies });
// This is not strictly necessary, since the defaultUser has this
// profileUI, but we're interested in how the profileUI is updated. As
// such, setting this explicitly isolates these tests.
@@ -122,6 +127,221 @@ describe('settingRoutes', () => {
}
});
describe('/confirm-email', () => {
const defaultErrorMessage = {
type: 'danger',
content:
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
} as const;
const successMessage = {
type: 'success',
content: 'flash.email-valid'
} as const;
const validToken =
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGy';
// This is a valid id for a token, but it doesn't exist in the database
const validButMissingToken =
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGY';
const tokenWithMissingUser =
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGH';
const expiredToken =
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGE';
const tokens = [validToken, tokenWithMissingUser, expiredToken];
const newEmail = 'anything@goes.com';
const encodedEmail = Buffer.from(newEmail).toString('base64');
const notEmail = Buffer.from('foobar.com').toString('base64');
beforeEach(async () => {
await fastifyTestInstance.prisma.authToken.create({
data: {
created: new Date(),
id: validToken,
ttl: 1000,
userId: defaultUserId
}
});
await fastifyTestInstance.prisma.authToken.create({
data: {
created: new Date(),
id: tokenWithMissingUser,
ttl: 1000,
// Random ObjectId
userId: '6650ac23ccc46c0349a86dee'
}
});
await fastifyTestInstance.prisma.authToken.create({
data: {
created: new Date(Date.now() - 1000),
id: expiredToken,
ttl: 1000,
userId: defaultUserId
}
});
// We expect these properties to be changed by the endpoint, so they
// need to be set so that change can be confirmed.
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
data: {
newEmail,
emailVerified: false,
emailVerifyTTL: new Date(),
emailAuthLinkTTL: new Date()
}
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.authToken.deleteMany({
where: { id: { in: tokens } }
});
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
data: { newEmail: null, email: defaultUserEmail, emailVerified: true }
});
});
it('should reject requests without params', async () => {
const resNoParams = await superGet('/confirm-email');
expect(resNoParams.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
);
expect(resNoParams.status).toBe(302);
});
it('should reject requests which have an invalid token param', async () => {
const res = await superGet(
// token should be 64 characters long
`/confirm-email?email=${encodedEmail}&token=tooshort`
);
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
);
expect(res.status).toBe(302);
});
it('should reject requests which have an invalid email param', async () => {
const res = await superGet(
`/confirm-email?email=${notEmail}&token=${validToken}`
);
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
);
expect(res.status).toBe(302);
});
it('should reject requests when the auth token is not in the database', async () => {
const res = await superGet(
`/confirm-email?email=${encodedEmail}&token=${validButMissingToken}`
);
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
);
expect(res.status).toBe(302);
});
it('should reject requests when the auth token exists, but the user does not', async () => {
const res = await superGet(
`/confirm-email?email=${encodedEmail}&token=${validButMissingToken}`
);
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
);
expect(res.status).toBe(302);
});
// TODO(Post-MVP): there's no need to keep the auth token around if,
// somehow, the user is missing
it.todo(
'should delete the auth token if there is no user associated with it'
);
it('should reject requests when the email param is different from user.newEmail', async () => {
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
data: { newEmail: 'an@oth.er' }
});
const res = await superGet(
`/confirm-email?email=${encodedEmail}&token=${validToken}`
);
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
);
expect(res.status).toBe(302);
});
it('should reject requests if the auth token has expired', async () => {
const res = await superGet(
`/confirm-email?email=${encodedEmail}&token=${expiredToken}`
);
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` +
formatMessage({
content:
'The link to confirm your new email address has expired. Please try again.',
type: 'info'
})
);
expect(res.status).toBe(302);
});
it('should update the user email', async () => {
const res = await superGet(
`/confirm-email?email=${encodedEmail}&token=${validToken}`
);
const user = await fastifyTestInstance.prisma.user.findUniqueOrThrow({
where: { id: defaultUserId }
});
expect(res.headers.location).toBe(
`${HOME_LOCATION}?` + formatMessage(successMessage)
);
expect(user.email).toBe(newEmail);
});
it('should clean up the user record', async () => {
await superGet(
`/confirm-email?email=${encodedEmail}&token=${validToken}`
);
const user = await fastifyTestInstance.prisma.user.findUniqueOrThrow({
where: { id: defaultUserId }
});
expect(user.newEmail).toBeNull();
expect(user.emailVerified).toBe(true);
expect(user.emailVerifyTTL).toBeNull();
expect(user.emailAuthLinkTTL).toBeNull();
});
it('should remove the auth token on success', async () => {
await superGet(
`/confirm-email?email=${encodedEmail}&token=${validToken}`
);
const authToken = await fastifyTestInstance.prisma.authToken.findUnique(
{
where: { id: validToken }
}
);
expect(authToken).toBeNull();
});
});
describe('/update-my-profileui', () => {
test('PUT returns 200 status code with "success" message', async () => {
const response = await superPut('/update-my-profileui').send({
@@ -799,37 +1019,51 @@ Happy coding!
expect(user?.isClassroomAccount).toEqual(false);
});
});
});
describe('Unauthenticated User', () => {
let setCookies: string[];
describe('Unauthenticated User', () => {
let setCookies: string[];
// Get the CSRF cookies from an unprotected route
beforeAll(async () => {
const res = await superRequest('/status/ping', { method: 'GET' });
setCookies = res.get('Set-Cookie');
});
// Get the CSRF cookies from an unprotected route
beforeAll(async () => {
const res = await superRequest('/status/ping', { method: 'GET' });
setCookies = res.get('Set-Cookie');
});
const endpoints: { path: string; method: 'PUT' }[] = [
{ path: '/update-my-profileui', method: 'PUT' },
{ path: '/update-my-theme', method: 'PUT' },
{ path: '/update-my-username', method: 'PUT' },
{ path: '/update-my-keyboard-shortcuts', method: 'PUT' },
{ path: '/update-my-socials', method: 'PUT' },
{ path: '/update-my-quincy-email', method: 'PUT' },
{ path: '/update-my-about', method: 'PUT' },
{ path: '/update-my-honesty', method: 'PUT' },
{ path: '/update-privacy-terms', method: 'PUT' },
{ path: '/update-my-portfolio', method: 'PUT' }
];
endpoints.forEach(({ path, method }) => {
test(`${method} ${path} returns 401 status code with error message`, async () => {
const response = await superRequest(path, {
method,
setCookies
});
expect(response.statusCode).toBe(401);
describe('/confirm-email', () => {
it('redirects to the HOME_LOCATION with flash message', async () => {
const res = await superRequest('/confirm-email', {
method: 'GET',
headers: { referer: 'https://who.knows/' }
});
expect(res.status).toBe(302);
expect(res.headers).toMatchObject({
location: `http://localhost:8000?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`
});
});
});
const endpoints: { path: string; method: 'PUT' }[] = [
{ path: '/update-my-profileui', method: 'PUT' },
{ path: '/update-my-theme', method: 'PUT' },
{ path: '/update-my-username', method: 'PUT' },
{ path: '/update-my-keyboard-shortcuts', method: 'PUT' },
{ path: '/update-my-socials', method: 'PUT' },
{ path: '/update-my-quincy-email', method: 'PUT' },
{ path: '/update-my-about', method: 'PUT' },
{ path: '/update-my-honesty', method: 'PUT' },
{ path: '/update-privacy-terms', method: 'PUT' },
{ path: '/update-my-portfolio', method: 'PUT' }
];
endpoints.forEach(({ path, method }) => {
test(`${method} ${path} returns 401 status code with error message`, async () => {
const response = await superRequest(path, {
method,
setCookies
});
expect(response.statusCode).toBe(401);
});
});
});

View File

@@ -5,6 +5,7 @@ import {
import type {
ContextConfigDefault,
FastifyError,
FastifyInstance,
FastifyReply,
FastifyRequest,
RawReplyDefaultExpression,
@@ -14,13 +15,17 @@ import type {
} from 'fastify';
import { ResolveFastifyReplyType } from 'fastify/types/type-provider';
import { differenceInMinutes } from 'date-fns';
import validator from 'validator';
import { isValidUsername } from '../../../shared/utils/validate';
import * as schemas from '../schemas';
import { createAuthToken } from '../utils/tokens';
import { createAuthToken, isExpired } from '../utils/tokens';
import { API_LOCATION } from '../utils/env';
import { getRedirectParams } from '../utils/redirection';
import { isRestricted } from './helpers/is-restricted';
const { isEmail } = validator;
type WaitMesssageArgs = {
sentAt: Date | null;
now?: Date;
@@ -439,6 +444,7 @@ ${isLinkSentWithinLimitTTL}`
}
}
);
fastify.put(
'/update-my-about',
{
@@ -662,3 +668,117 @@ ${isLinkSentWithinLimitTTL}`
done();
};
/**
* Plugin for endpoints that redirect if the user is not authenticated.
*
* @param fastify The Fastify instance.
* @param _options Options for the plugin.
* @param done Callback to signal that the logic has completed.
*/
export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
const redirectMessage = {
type: 'danger',
content:
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
} as const;
const expirationMessage = {
type: 'info',
content:
'The link to confirm your new email address has expired. Please try again.'
} as const;
const successMessage = {
type: 'success',
content: 'flash.email-valid'
} as const;
async function updateEmail(
fastify: FastifyInstance,
{ id, email }: { id: string; email: string }
) {
await fastify.prisma.user.update({
where: { id },
data: {
email,
emailAuthLinkTTL: null,
emailVerified: true,
emailVerifyTTL: null,
newEmail: null
}
});
}
async function deleteAuthToken(
fastify: FastifyInstance,
{ id }: { id: string }
) {
await fastify.prisma.authToken.delete({
where: { id }
});
}
fastify.get(
'/confirm-email',
{
schema: schemas.confirmEmail,
errorHandler(error, request, reply) {
if (error.validation) {
const { origin } = getRedirectParams(request);
void reply.redirectWithMessage(origin, redirectMessage);
} else {
fastify.errorHandler(error, request, reply);
}
}
},
async (req, reply) => {
const email = Buffer.from(req.query.email, 'base64').toString();
const { origin } = getRedirectParams(req);
if (!isEmail(email)) {
return reply.redirectWithMessage(origin, redirectMessage);
}
const authToken = await fastify.prisma.authToken.findUnique({
where: { id: req.query.token }
});
if (!authToken) {
return reply.redirectWithMessage(origin, redirectMessage);
}
// TODO(Post-MVP): clean up expired auth tokens.
if (isExpired(authToken)) {
return reply.redirectWithMessage(origin, expirationMessage);
}
// TODO(Post-MVP): should this fail if it's not the currently signed in
// user?
const targetUser = await fastify.prisma.user.findUnique({
where: { id: authToken.userId }
});
if (targetUser?.newEmail !== email) {
return reply.redirectWithMessage(origin, redirectMessage);
}
// TODO(Post-MVP): clean up any other auth tokens for this user once
// the email is confirmed.
await Promise.all([
updateEmail(fastify, { id: targetUser.id, email }),
deleteAuthToken(fastify, { id: authToken.id })
]);
return reply.redirectWithMessage(origin, successMessage);
}
);
done();
};

View File

@@ -15,6 +15,7 @@ export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
export { resubscribe } from './schemas/email-subscription/resubscribe';
export { unsubscribe } from './schemas/email-subscription/unsubscribe';
export { updateMyAbout } from './schemas/settings/update-my-about';
export { confirmEmail } from './schemas/settings/confirm-email';
export { updateMyClassroomMode } from './schemas/settings/update-my-classroom-mode';
export { updateMyEmail } from './schemas/settings/update-my-email';
export { updateMyHonesty } from './schemas/settings/update-my-honesty';

View File

@@ -0,0 +1,8 @@
import { Type } from '@fastify/type-provider-typebox';
export const confirmEmail = {
querystring: Type.Object({
email: Type.String({ maxLength: 1000 }),
token: Type.String({ minLength: 64, maxLength: 64 })
})
};

View File

@@ -1,5 +1,6 @@
jest.useFakeTimers();
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createAccessToken, createAuthToken } from './tokens';
import { createAccessToken, createAuthToken, isExpired } from './tokens';
describe('createAccessToken', () => {
it('creates an object with id, ttl, created and userId', () => {
@@ -52,3 +53,25 @@ describe('createAuthToken', () => {
expect(createAuthToken(userId).ttl).toBe(900000);
});
});
describe('isExpired', () => {
it('returns true if the token expiry date is in the past', () => {
const token = createAccessToken('abc', 1000);
expect(isExpired(token)).toBe(false);
jest.advanceTimersByTime(500);
expect(isExpired(token)).toBe(false);
jest.advanceTimersByTime(500);
expect(isExpired(token)).toBe(false);
jest.advanceTimersByTime(1);
expect(isExpired(token)).toBe(true);
});
it('handles tokens with Date values for created', () => {
const token = { ...createAccessToken('abc', 2000), created: new Date() };
expect(isExpired(token)).toBe(false);
jest.advanceTimersByTime(2000);
expect(isExpired(token)).toBe(false);
jest.advanceTimersByTime(1);
expect(isExpired(token)).toBe(true);
});
});

View File

@@ -20,6 +20,13 @@ export type Token = {
created: string;
};
type DbToken = {
userId: string;
id: string;
ttl: number;
created: Date;
};
/**
* Creates an access token.
* @param userId The user ID as a string (yes, it's an ObjectID, but it will be serialized to a string anyway).
@@ -49,3 +56,13 @@ export const createAuthToken = (userId: string, ttl?: number): Token => {
created: new Date().toISOString()
};
};
/**
* Check if an access token has expired.
* @param token The access token to check.
* @returns True if the token has expired, false otherwise.
*/
export const isExpired = (token: Token | DbToken): boolean => {
const created = new Date(token.created);
return Date.now() > created.getTime() + token.ttl;
};