feat(api): use jwt_access_token (in development) (#53997)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-03-20 12:47:12 +01:00
committed by GitHub
parent bb2efe7618
commit aacfb281fb
25 changed files with 871 additions and 289 deletions

View File

@@ -12,8 +12,8 @@ declare global {
}
type Options = {
sendCSRFToken: boolean;
};
sendCSRFToken?: boolean;
} & Record<string, unknown>;
const requests = {
GET: (resource: string) => request(fastifyTestInstance?.server).get(resource),
@@ -187,8 +187,8 @@ export async function devLogin(): Promise<string[]> {
id: defaultUserId
}
});
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
expect(res.status).toBe(200);
const res = await superRequest('/signin', { method: 'GET' });
expect(res.status).toBe(302);
return res.get('Set-Cookie');
}

View File

@@ -1,4 +1,3 @@
import fastifyCookie from '@fastify/cookie';
import fastifyCsrfProtection from '@fastify/csrf-protection';
import express from '@fastify/express';
import fastifySession from '@fastify/session';
@@ -20,6 +19,7 @@ import Fastify, {
} from 'fastify';
import prismaPlugin from './db/prisma';
import cookies from './plugins/cookies';
import cors from './plugins/cors';
import { NodemailerProvider } from './plugins/mail-providers/nodemailer';
import { SESProvider } from './plugins/mail-providers/ses';
@@ -27,11 +27,9 @@ import mailer from './plugins/mailer';
import redirectWithMessage from './plugins/redirect-with-message';
import security from './plugins/security';
import sessionAuth from './plugins/session-auth';
import {
devLoginCallback,
devLegacyAuthRoutes,
mobileAuth0Routes
} from './routes/auth';
import codeFlowAuth from './plugins/code-flow-auth';
import { mobileAuth0Routes } from './routes/auth';
import { devAuthRoutes } from './routes/auth-dev';
import { challengeRoutes } from './routes/challenge';
import { deprecatedEndpoints } from './routes/deprecated-endpoints';
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
@@ -108,7 +106,7 @@ export const build = async (
}
await fastify.register(cors);
await fastify.register(fastifyCookie);
await fastify.register(cookies);
void fastify.register(fastifyCsrfProtection, {
// TODO: consider signing cookies. We don't on the api-server, but we could
@@ -119,17 +117,21 @@ export const build = async (
getToken: req => req.headers['csrf-token'] as string
});
// All routes should add a CSRF token to the response
// All routes except signout should add a CSRF token to the response
fastify.addHook('onRequest', (_req, reply, done) => {
const token = reply.generateCsrf();
// Path is necessary to ensure that only one cookie is set and it is valid
// for all routes.
void reply.setCookie('csrf_token', token, {
path: '/',
sameSite: 'strict',
domain: COOKIE_DOMAIN,
secure: FREECODECAMP_NODE_ENV === 'production'
});
const isSignout = _req.url === '/signout' || _req.url === '/signout/';
if (!isSignout) {
const token = reply.generateCsrf();
// Path is necessary to ensure that only one cookie is set and it is valid
// for all routes.
void reply.setCookie('csrf_token', token, {
path: '/',
sameSite: 'strict',
domain: COOKIE_DOMAIN,
secure: FREECODECAMP_NODE_ENV === 'production'
});
}
done();
});
@@ -191,11 +193,11 @@ export const build = async (
}
void fastify.register(sessionAuth);
void fastify.register(codeFlowAuth);
void fastify.register(prismaPlugin);
void fastify.register(mobileAuth0Routes);
if (FCC_ENABLE_DEV_LOGIN_MODE) {
void fastify.register(devLoginCallback, { prefix: '/auth' });
void fastify.register(devLegacyAuthRoutes);
void fastify.register(devAuthRoutes);
}
void fastify.register(certificateRoutes);
void fastify.register(challengeRoutes);

View File

@@ -0,0 +1,205 @@
import Fastify, { FastifyInstance } from 'fastify';
import jwt from 'jsonwebtoken';
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
import { createAccessToken } from '../utils/tokens';
import cookies, { sign as signCookie } from './cookies';
import codeFlowAuth from './code-flow-auth';
describe('auth', () => {
let fastify: FastifyInstance;
beforeEach(async () => {
fastify = Fastify();
await fastify.register(cookies);
await fastify.register(codeFlowAuth);
});
afterEach(async () => {
await fastify.close();
});
describe('setAccessTokenCookie', () => {
// We won't need to keep doubly signing the cookie when we migrate the
// authentication, but for the MVP we have to be able to read the cookies
// set by the api-server. So, double signing:
it('should doubly sign the cookie', async () => {
const token = createAccessToken('test-id');
fastify.get('/test', async (req, reply) => {
reply.setAccessTokenCookie(token);
return { ok: true };
});
const singlySignedToken = jwt.sign({ accessToken: token }, JWT_SECRET);
const doublySignedToken = signCookie(singlySignedToken);
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.cookies[0]).toEqual(
expect.objectContaining({
name: 'jwt_access_token',
value: doublySignedToken,
path: '/',
sameSite: 'Lax',
domain: COOKIE_DOMAIN
})
);
});
// TODO: Post-MVP sync the cookie max-age with the token ttl (i.e. the
// max-age should be the ttl/1000, not ttl)
it('should set the max-age of the cookie to match the ttl of the token', async () => {
const token = createAccessToken('test-id', 123000);
fastify.get('/test', async (req, reply) => {
reply.setAccessTokenCookie(token);
return { ok: true };
});
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.cookies[0]).toEqual(
expect.objectContaining({
maxAge: 123000
})
);
});
});
describe('authorize', () => {
beforeEach(() => {
fastify.addHook('onRequest', fastify.authorize);
fastify.get('/test', () => {
return { message: 'ok' };
});
});
it('should reject if the access token is missing', async () => {
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.json()).toEqual({
type: 'info',
message: 'Access token is required for this request'
});
expect(res.statusCode).toBe(401);
});
it('should reject 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.json()).toEqual({
type: 'info',
message: 'Access token is required for this request'
});
expect(res.statusCode).toBe(401);
});
it('should reject 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.json()).toEqual({
type: 'info',
message: 'Your access token is invalid'
});
expect(res.statusCode).toBe(401);
});
it('should reject 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.json()).toEqual({
type: 'info',
message: 'Access token is no longer valid'
});
expect(res.statusCode).toBe(401);
});
it('should reject 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.json()).toEqual({
type: 'info',
message: 'Your access token is invalid'
});
});
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

@@ -0,0 +1,86 @@
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 { AccessToken } from '../utils/tokens';
declare module 'fastify' {
interface FastifyReply {
setAccessTokenCookie: (
this: FastifyReply,
accessToken: AccessToken
) => void;
}
interface FastifyRequest {
// TODO: is the full user the correct type here?
user?: user;
}
interface FastifyInstance {
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
}
}
const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
fastify.decorateReply(
'setAccessTokenCookie',
function (accessToken: AccessToken) {
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
void this.setCookie('jwt_access_token', signedToken, {
path: '/',
httpOnly: false,
secure: false,
sameSite: 'lax',
domain: COOKIE_DOMAIN,
signed: true,
maxAge: accessToken.ttl
});
}
);
const TOKEN_REQUIRED = 'Access token is required for this request';
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 });
fastify.decorate(
'authorize',
async function (req: FastifyRequest, reply: FastifyReply) {
const tokenCookie = req.cookies.jwt_access_token;
if (!tokenCookie) return send401(reply, TOKEN_REQUIRED);
const unsignedToken = req.unsignCookie(tokenCookie);
if (!unsignedToken.valid) return send401(reply, TOKEN_REQUIRED);
const jwtAccessToken = unsignedToken.value;
try {
jwt.verify(jwtAccessToken!, JWT_SECRET);
} catch {
return send401(reply, TOKEN_INVALID);
}
const {
accessToken: { created, ttl, userId }
} = jwt.decode(jwtAccessToken!) as { accessToken: AccessToken };
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
if (!valid) return send401(reply, TOKEN_EXPIRED);
const user = await fastify.prisma.user.findUnique({
where: { id: userId }
});
if (!user) return send401(reply, TOKEN_INVALID);
req.user = user;
}
);
done();
};
export default fp(codeFlowAuth);

View File

@@ -0,0 +1,70 @@
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { COOKIE_SECRET } from '../utils/env';
import cookies from './cookies';
describe('cookies', () => {
let fastify: FastifyInstance;
beforeEach(async () => {
fastify = Fastify();
await fastify.register(cookies);
});
afterEach(async () => {
await fastify.close();
});
it('should prefix signed cookies with "s:" (url-encoded)', async () => {
fastify.get('/test', async (req, reply) => {
void reply.setCookie('test', 'value', { signed: true });
return { ok: true };
});
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.headers['set-cookie']).toMatch(/test=s%3Avalue\.\w*/);
});
it('should be able to unsign cookies', async () => {
const signedCookie = `test=s%3A${fastifyCookie.sign('value', COOKIE_SECRET)}`;
fastify.get('/test', (req, reply) => {
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
});
const res = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
cookie: signedCookie
}
});
expect(res.json()).toEqual({
unsigned: { value: 'value', renew: false, valid: true }
});
});
it('should reject cookies not prefixed with "s:"', async () => {
const signedCookie = `test=${fastifyCookie.sign('value', COOKIE_SECRET)}`;
fastify.get('/test', (req, reply) => {
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
});
const res = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
cookie: signedCookie
}
});
expect(res.json()).toEqual({
unsigned: { value: null, renew: false, valid: false }
});
});
});

View File

@@ -0,0 +1,50 @@
import fastifyCookie from '@fastify/cookie';
import { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { COOKIE_SECRET } from '../utils/env';
/**
* Signs a cookie value by prefixing it with "s:" and using the COOKIE_SECRET.
*
* @param value The value to sign.
* @returns The signed cookie value.
*/
export const sign = (value: string) =>
's:' + fastifyCookie.sign(value, COOKIE_SECRET);
/**
* Unsigns a cookie value by removing the "s:" prefix and using the COOKIE_SECRET.
*
* @param rawValue The signed cookie value.
* @returns The unsigned cookie value.
*/
export const unsign = (rawValue: string) => {
const prefix = rawValue.slice(0, 2);
if (prefix !== 's:') return { valid: false, renew: false, value: null };
const value = rawValue.slice(2);
return fastifyCookie.unsign(value, COOKIE_SECRET);
};
/**
* Compatibility plugin for using cookies signed by express. By prefixing with
* "s:" and removing it when unsigning, we can continue to use the same cookies
* in Fastify.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done Callback to signal that the logic has completed.
*/
const cookies: FastifyPluginCallback = (fastify, _options, done) => {
void fastify.register(fastifyCookie, {
secret: {
sign,
unsign
}
});
done();
};
export default fp(cookies);

View File

@@ -2,15 +2,7 @@ import { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { HOME_LOCATION } from '../utils/env';
const allowedOrigins = [
'https://www.freecodecamp.dev',
'https://www.freecodecamp.org',
'https://beta.freecodecamp.dev',
'https://beta.freecodecamp.org',
'https://chinese.freecodecamp.dev',
'https://chinese.freecodecamp.org'
];
import { allowedOrigins } from '../utils/allowed-origins';
const cors: FastifyPluginCallback = (fastify, _options, done) => {
fastify.options('*', (_req, reply) => {

View File

@@ -0,0 +1,199 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
defaultUserEmail,
devLogin,
setupServer,
superRequest
} from '../../jest.utils';
import { HOME_LOCATION } from '../utils/env';
describe('dev login', () => {
setupServer();
beforeEach(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: defaultUserEmail }
});
});
afterAll(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: defaultUserEmail }
});
});
describe('GET /signin', () => {
it('should create an account if one does not exist', async () => {
const res = await superRequest('/signin', { method: 'GET' });
const count = await fastifyTestInstance.prisma.user.count({
where: { email: defaultUserEmail }
});
expect(count).toBe(1);
expect(res.body).toStrictEqual({});
expect(res.status).toBe(302);
});
it('should populate the user with the correct data', async () => {
const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
await superRequest('/signin', { method: 'GET' });
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: defaultUserEmail }
});
expect(user).toMatchObject({
about: '',
acceptedPrivacyTerms: false,
completedChallenges: [],
currentChallengeId: '',
email: defaultUserEmail,
emailVerified: true,
externalId: expect.stringMatching(uuidRe),
is2018DataVisCert: false,
is2018FullStackCert: false,
isApisMicroservicesCert: false,
isBackEndCert: false,
isBanned: false,
isCheater: false,
isDataAnalysisPyCertV7: false,
isDataVisCert: false,
isDonating: false,
isFrontEndCert: false,
isFrontEndLibsCert: false,
isFullStackCert: false,
isHonest: false,
isInfosecCertV7: false,
isInfosecQaCert: false,
isJsAlgoDataStructCert: false,
isMachineLearningPyCertV7: false,
isQaCertV7: false,
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
isRespWebDesignCert: false,
isSciCompPyCertV7: false,
keyboardShortcuts: false,
location: '',
name: '',
unsubscribeId: '',
picture: '',
profileUI: {
isLocked: false,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
},
progressTimestamps: [],
sendQuincyEmail: false,
theme: 'default',
username: expect.stringMatching(fccUuidRe),
usernameDisplay: expect.stringMatching(fccUuidRe)
});
expect(user.username).toBe(user.usernameDisplay);
});
it('should set the jwt_access_token cookie', async () => {
const res = await superRequest('/signin', { method: 'GET' });
expect(res.status).toBe(302);
expect(res.headers['set-cookie']).toEqual(
expect.arrayContaining([expect.stringMatching(/jwt_access_token=/)])
);
// TODO: check the cookie value
});
it.todo('should create a session');
// duplicate of the server.test test to make sure I've not done something
// silly
it('should have OWASP recommended headers', async () => {
const res = await superRequest('/signin', { method: 'GET' });
expect(res.headers).toMatchObject({
'cache-control': 'no-store',
'content-security-policy': "frame-ancestors 'none'",
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'
});
});
it('should redirect to the Referer (if it is a valid origin)', async () => {
const res = await superRequest('/signin', { method: 'GET' }).set(
'referer',
'https://www.freecodecamp.org/some-path/or/other'
);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(
'https://www.freecodecamp.org/some-path/or/other'
);
});
it('should redirect to /valid-language/learn when signing in from /valid-language', async () => {
const res = await superRequest('/signin', { method: 'GET' }).set(
'referer',
'https://www.freecodecamp.org/espanol'
);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(
'https://www.freecodecamp.org/espanol/learn'
);
});
it('should handle referers with trailing slahes', async () => {
const res = await superRequest('/signin', {
method: 'GET'
}).set('referer', 'https://www.freecodecamp.org/espanol/');
expect(res.status).toBe(302);
expect(res.headers.location).toBe(
'https://www.freecodecamp.org/espanol/learn'
);
});
it('should redirect to /learn by default', async () => {
const res = await superRequest('/signin', { method: 'GET' });
expect(res.status).toBe(302);
expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`);
});
});
describe('GET /signout', () => {
beforeEach(async () => {
await devLogin();
});
it('should clear all the cookies', async () => {
const res = await superRequest('/signout', { method: 'GET' });
const setCookie = res.headers['set-cookie'];
expect(setCookie).toEqual(
expect.arrayContaining([
'jwt_access_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
'_csrf=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
'csrf_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT'
])
);
expect(setCookie).toHaveLength(3);
});
it('should redirect to / on the client by default', async () => {
const res = await superRequest('/signout', { method: 'GET' });
// This happens because localhost:8000 is not an allowed origin and so
// normalizeParams rejects it and sets the returnTo to /learn. TODO:
// separate the validation and normalization logic.
expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`);
expect(res.status).toBe(302);
});
});
});

View File

@@ -0,0 +1,58 @@
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
import { createAccessToken } from '../utils/tokens';
import {
getPrefixedLandingPath,
getRedirectParams,
haveSamePath
} from '../utils/redirection';
import { findOrCreateUser } from './helpers/auth-helpers';
const trimTrailingSlash = (str: string) =>
str.endsWith('/') ? str.slice(0, -1) : str;
/**
* Route handler for development login.
*
* @deprecated
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin,
* options)`.
* @param done Callback to signal that the logic has completed.
*/
export const devAuthRoutes: FastifyPluginCallback = (
fastify,
_options,
done
) => {
async function handleRedirects(req: FastifyRequest, reply: FastifyReply) {
const params = getRedirectParams(req);
const { origin, pathPrefix } = params;
const returnTo = trimTrailingSlash(params.returnTo);
const landingUrl = getPrefixedLandingPath(origin, pathPrefix);
return await reply.redirect(
haveSamePath(landingUrl, returnTo) ? `${returnTo}/learn` : returnTo
);
}
fastify.get('/signin', async (req, reply) => {
const email = 'foo@bar.com';
const { id } = await findOrCreateUser(fastify, email);
reply.setAccessTokenCookie(createAccessToken(id));
await handleRedirects(req, reply);
});
fastify.get('/signout', async (req, reply) => {
void reply.clearCookie('jwt_access_token');
void reply.clearCookie('csrf_token');
void reply.clearCookie('_csrf');
const { returnTo } = getRedirectParams(req);
await reply.redirect(returnTo);
});
done();
};

View File

@@ -1,95 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { defaultUserEmail, setupServer, superRequest } from '../../jest.utils';
describe('dev login', () => {
setupServer();
beforeEach(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: defaultUserEmail }
});
});
afterAll(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: defaultUserEmail }
});
});
it('should create an account if one does not exist', async () => {
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
const count = await fastifyTestInstance.prisma.user.count({
where: { email: defaultUserEmail }
});
expect(count).toBe(1);
expect(res.body).toStrictEqual({ statusCode: 200 });
expect(res.status).toBe(200);
});
it('should populate the user with the correct data', async () => {
const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
await superRequest('/auth/dev-callback', { method: 'GET' });
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: defaultUserEmail }
});
expect(user).toMatchObject({
about: '',
acceptedPrivacyTerms: false,
completedChallenges: [],
currentChallengeId: '',
email: defaultUserEmail,
emailVerified: true,
externalId: expect.stringMatching(uuidRe),
is2018DataVisCert: false,
is2018FullStackCert: false,
isApisMicroservicesCert: false,
isBackEndCert: false,
isBanned: false,
isCheater: false,
isDataAnalysisPyCertV7: false,
isDataVisCert: false,
isDonating: false,
isFrontEndCert: false,
isFrontEndLibsCert: false,
isFullStackCert: false,
isHonest: false,
isInfosecCertV7: false,
isInfosecQaCert: false,
isJsAlgoDataStructCert: false,
isMachineLearningPyCertV7: false,
isQaCertV7: false,
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
isRespWebDesignCert: false,
isSciCompPyCertV7: false,
keyboardShortcuts: false,
location: '',
name: '',
unsubscribeId: '',
picture: '',
profileUI: {
isLocked: false,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
},
progressTimestamps: [],
sendQuincyEmail: false,
theme: 'default',
username: expect.stringMatching(fccUuidRe),
usernameDisplay: expect.stringMatching(fccUuidRe)
});
expect(user.username).toBe(user.usernameDisplay);
});
});

View File

@@ -1,15 +1,11 @@
import {
FastifyInstance,
FastifyPluginCallback,
FastifyRequest
} from 'fastify';
import { FastifyPluginCallback, FastifyRequest } from 'fastify';
import rateLimit from 'express-rate-limit';
// @ts-expect-error - no types
import MongoStoreRL from 'rate-limit-mongo';
import { createUserInput } from '../utils/create-user';
import { AUTH0_DOMAIN, HOME_LOCATION, MONGOHQ_URL } from '../utils/env';
import { AUTH0_DOMAIN, MONGOHQ_URL } from '../utils/env';
import { findOrCreateUser } from './helpers/auth-helpers';
declare module 'fastify' {
interface Session {
@@ -35,49 +31,6 @@ const getEmailFromAuth0 = async (req: FastifyRequest) => {
return email;
};
const findOrCreateUser = async (fastify: FastifyInstance, email: string) => {
// TODO: handle the case where there are multiple users with the same email.
// e.g. use findMany and throw an error if more than one is found.
const existingUser = await fastify.prisma.user.findFirst({
where: { email },
select: { id: true }
});
return (
existingUser ??
(await fastify.prisma.user.create({
data: createUserInput(email),
select: { id: true }
}))
);
};
/**
* Route handler for development login. This is only used in local
* development, and bypasses Auth0, authenticating as the development
* user.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done Callback to signal that the logic has completed.
*/
// TODO: 1) use POST 2) make sure we prevent login CSRF
export const devLoginCallback: FastifyPluginCallback = (
fastify,
_options,
done
) => {
fastify.get('/dev-callback', async req => {
const email = 'foo@bar.com';
const { id } = await findOrCreateUser(fastify, email);
req.session.user = { id };
await req.session.save();
return { statusCode: 200 };
});
done();
};
/**
* Route handler for Mobile authentication.
*
@@ -120,36 +73,3 @@ export const mobileAuth0Routes: FastifyPluginCallback = (
done();
};
/**
* Legacy route handler for development login. This mimics the behaviour of old
* api-server which the client depends on for authentication. The key difference
* is that this uses a different cookie (not jwt_access_token), and, if we want
* to use this for real, we will need to account for that.
*
* @deprecated
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin,
* options)`.
* @param done Callback to signal that the logic has completed.
*/
export const devLegacyAuthRoutes: FastifyPluginCallback = (
fastify,
_options,
done
) => {
fastify.get('/signin', async (req, reply) => {
const email = 'foo@bar.com';
const { id } = await findOrCreateUser(fastify, email);
req.session.user = { id };
await req.session.save();
await reply.redirect(HOME_LOCATION + '/learn');
});
fastify.get('/signout', async (req, reply) => {
await req.session.destroy();
await reply.redirect(HOME_LOCATION + '/learn');
});
done();
};

View File

@@ -79,7 +79,9 @@ describe('certificate routes', () => {
expect(response.status).toBe(400);
});
test('should return 500 if user not found in db', async () => {
// TODO: Revisit this test after deciding if we need/want to fetch the
// entire user during authorization or just the user id.
test.skip('should return 500 if user not found in db', async () => {
jest
.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
.mockImplementation(

View File

@@ -57,7 +57,7 @@ export const certificateRoutes: FastifyPluginCallbackTypebox = (
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.addHook('onRequest', fastify.authorize);
// TODO(POST_MVP): Response should not include updated user. If a client wants the updated user, it should make a separate request
// OR: Always respond with current user - full user object - not random pieces.
@@ -102,7 +102,7 @@ export const certificateRoutes: FastifyPluginCallbackTypebox = (
try {
const user = await fastify.prisma.user.findUnique({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
if (!user) {

View File

@@ -56,7 +56,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.addHook('onRequest', fastify.authorize);
fastify.post(
'/coderoad-challenge-completed',
@@ -150,7 +150,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
};
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
partiallyCompletedChallenges: uniqBy(
[finalChallenge, ...partiallyCompletedChallenges],
@@ -193,7 +193,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
},
async (req, reply) => {
const { id: projectId, challengeType, solution, githubLink } = req.body;
const userId = req.session.user.id;
const userId = req.user?.id;
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
@@ -281,7 +281,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const progressTimestamps = user.progressTimestamps as
| ProgressTimestamp[]
@@ -335,7 +335,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
const { id, files, challengeType } = req.body;
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const RawProgressTimestamp = user.progressTimestamps as
| ProgressTimestamp[]
@@ -399,7 +399,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
try {
const { files, id: challengeId } = req.body;
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const challenge = {
id: challengeId,
@@ -459,7 +459,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
const { completedChallenges } =
await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id },
where: { id: req.user?.id },
select: { completedChallenges: true }
});
@@ -550,7 +550,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
}
const msUser = await fastify.prisma.msUsername.findFirst({
where: { userId: req.session.user.id }
where: { userId: req.user?.id }
});
if (!msUser || !msUser.msUsername) {
@@ -574,7 +574,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
}
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id },
where: { id: req.user?.id },
select: { completedChallenges: true, progressTimestamps: true }
});
@@ -596,7 +596,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
solution: msTrophyStatus.msUserAchievementsApiUrl
};
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
completedChallenges: {
push: newChallenge
@@ -639,7 +639,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
},
async (req, reply) => {
try {
const { id: userId } = req.session.user;
const userId = req.user?.id;
const { userCompletedExam, id, challengeType } = req.body;
const { completedChallenges, completedExams, progressTimestamps } =

View File

@@ -32,7 +32,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.addHook('onRequest', fastify.authorize);
fastify.post(
'/donate/add-donation',
{
@@ -56,7 +56,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const user = await fastify.prisma.user.findUnique({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
if (user?.isDonating) {
@@ -68,7 +68,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
}
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
isDonating: true
}
@@ -96,7 +96,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const { paymentMethodId, amount, duration } = req.body;
const { id } = req.session.user;
const id = req.user!.id;
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id }

View File

@@ -0,0 +1,27 @@
import { FastifyInstance } from 'fastify';
import { createUserInput } from '../../utils/create-user';
/**
* Finds an existing user with the given email or creates a new user if none exists.
* @param fastify - The Fastify instance.
* @param email - The email of the user.
* @returns The existing or newly created user.
*/
export const findOrCreateUser = async (
fastify: FastifyInstance,
email: string
) => {
// TODO: handle the case where there are multiple users with the same email.
// e.g. use findMany and throw an error if more than one is found.
const existingUser = await fastify.prisma.user.findFirst({
where: { email },
select: { id: true }
});
return (
existingUser ??
(await fastify.prisma.user.create({
data: createUserInput(email),
select: { id: true }
}))
);
};

View File

@@ -79,7 +79,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.addHook('onRequest', fastify.authorize);
type CommonResponseSchema = {
response: { 400: (typeof schemas.updateMyProfileUI.response)[400] };
@@ -123,7 +123,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
profileUI: {
isLocked: req.body.profileUI.isLocked,
@@ -167,7 +167,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
}
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id },
where: { id: req.user?.id },
select: {
email: true,
emailVerifyTTL: true,
@@ -216,7 +216,7 @@ ${isLinkSentWithinLimitTTL}`
// ToDo(MVP): email the new email and wait user to confirm it, before we update the user schema.
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
newEmail,
emailVerified: false,
@@ -239,7 +239,7 @@ ${isLinkSentWithinLimitTTL}`
}
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
emailAuthLinkTTL: new Date()
}
@@ -263,7 +263,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
theme: req.body.theme
}
@@ -290,7 +290,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
website: req.body.website,
twitter: req.body.twitter,
@@ -320,7 +320,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
const user = await fastify.prisma.user.findFirstOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const newUsernameDisplay = req.body.username.trim();
@@ -379,7 +379,7 @@ ${isLinkSentWithinLimitTTL}`
}
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
username: newUsername,
usernameDisplay: newUsernameDisplay
@@ -408,7 +408,7 @@ ${isLinkSentWithinLimitTTL}`
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
about: req.body.about,
name: req.body.name,
@@ -438,7 +438,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
keyboardShortcuts: req.body.keyboardShortcuts
}
@@ -465,7 +465,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
sendQuincyEmail: req.body.sendQuincyEmail
}
@@ -492,7 +492,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
isHonest: req.body.isHonest
}
@@ -519,7 +519,7 @@ ${isLinkSentWithinLimitTTL}`
async (req, reply) => {
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
acceptedPrivacyTerms: true,
sendQuincyEmail: req.body.quincyEmails
@@ -558,7 +558,7 @@ ${isLinkSentWithinLimitTTL}`
})
);
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user?.id },
data: {
portfolio
}
@@ -594,7 +594,7 @@ ${isLinkSentWithinLimitTTL}`
const classroomMode = req.body.isClassroomAccount;
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user!.id },
data: {
isClassroomAccount: classroomMode
}

View File

@@ -525,6 +525,14 @@ describe('userRoutes', () => {
expect(response.statusCode).toBe(500);
});
// This should help debugging, since this the route returns this if
// anything throws in the handler.
test('GET does not return the error response if the request is valid', async () => {
const response = await superGet('/user/get-session-user');
expect(response.body).not.toEqual({ user: {}, result: '' });
});
test('GET returns username as the result property', async () => {
const response = await superGet('/user/get-session-user');
@@ -597,7 +605,7 @@ describe('userRoutes', () => {
});
// devLogin must not be used here since it overrides the user
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
const res = await superRequest('/signin', { method: 'GET' });
const setCookies = res.get('Set-Cookie');
const publicUser = {

View File

@@ -1,8 +1,10 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { ObjectId } from 'mongodb';
import { customAlphabet } from 'nanoid';
import { schemas } from '../schemas';
// Loopback creates a 64 character string for the user id, this customizes
// nanoid to do the same. Any unique key _should_ be fine, though.
import { customNanoid } from '../utils/ids';
import {
normalizeChallenges,
normalizeProfileUI,
@@ -15,17 +17,10 @@ import {
getPoints,
type ProgressTimestamp
} from '../utils/progress';
import { encodeUserToken } from '../utils/user-token';
import { encodeUserToken } from '../utils/tokens';
import { trimTags } from '../utils/validation';
import { generateReportEmail } from '../utils/email-templates';
// Loopback creates a 64 character string for the user id, this customizes
// nanoid to do the same. Any unique key _should_ be fine, though.
const nanoid = customAlphabet(
'1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
64
);
/**
* Helper function to get the api url from the shared transcript link.
*
@@ -60,7 +55,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.addHook('onRequest', fastify.authorize);
fastify.post(
'/account/delete',
@@ -70,16 +65,16 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
await fastify.prisma.userToken.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
await fastify.prisma.msUsername.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
await fastify.prisma.survey.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
await fastify.prisma.user.delete({
where: { id: req.session.user.id }
where: { id: req.user!.id }
});
await req.session.destroy();
void reply.clearCookie('sessionId');
@@ -105,16 +100,16 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
await fastify.prisma.userToken.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
await fastify.prisma.msUsername.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
await fastify.prisma.survey.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
await fastify.prisma.user.update({
where: { id: req.session.user.id },
where: { id: req.user!.id },
data: {
progressTimestamps: [Date.now()],
currentChallengeId: '',
@@ -159,14 +154,14 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
// TODO(Post-MVP): POST -> PUT
fastify.post('/user/user-token', async req => {
await fastify.prisma.userToken.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user?.id }
});
const token = await fastify.prisma.userToken.create({
data: {
created: new Date(),
id: nanoid(),
userId: req.session.user.id,
id: customNanoid(),
userId: req.user!.id,
// TODO(Post-MVP): expire after ttl has passed.
ttl: 77760000000 // 900 * 24 * 60 * 60 * 1000
}
@@ -185,7 +180,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const { count } = await fastify.prisma.userToken.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user?.id }
});
if (count === 0) {
@@ -220,7 +215,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const { username, reportDescription: report } = req.body;
@@ -267,7 +262,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
await fastify.prisma.msUsername.deleteMany({
where: { userId: req.session.user.id }
where: { userId: req.user?.id }
});
// TODO(Post-MVP): return a generic success message.
@@ -301,7 +296,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const msApiRes = await fetch(
@@ -387,7 +382,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
async (req, reply) => {
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
where: { id: req.user?.id }
});
const { surveyResults } = req.body;
const { title } = surveyResults;
@@ -446,7 +441,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
_options,
done
) => {
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.addHook('onRequest', fastify.authorize);
fastify.get(
'/user/get-session-user',
@@ -456,11 +451,11 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
async (req, res) => {
try {
const userTokenP = fastify.prisma.userToken.findFirst({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
const userP = fastify.prisma.user.findUnique({
where: { id: req.session.user.id },
where: { id: req.user!.id },
select: {
about: true,
acceptedPrivacyTerms: true,
@@ -512,7 +507,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
});
const completedSurveysP = fastify.prisma.survey.findMany({
where: { userId: req.session.user.id }
where: { userId: req.user!.id }
});
const [userToken, user, completedSurveys] = await Promise.all([

View File

@@ -55,6 +55,7 @@ assert.ok(process.env.JWT_SECRET);
assert.ok(process.env.STRIPE_SECRET_KEY);
assert.ok(process.env.SHOW_UPCOMING_CHANGES);
assert.ok(process.env.MONGOHQ_URL);
assert.ok(process.env.COOKIE_SECRET);
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
assert.ok(process.env.SES_ID);
@@ -66,6 +67,7 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
);
assert.ok(process.env.SES_REGION);
assert.ok(process.env.COOKIE_DOMAIN);
assert.notEqual(process.env.COOKIE_SECRET, 'a_cookie_secret');
assert.ok(process.env.PORT);
assert.ok(process.env.SENTRY_DSN);
// The following values can exist in development, but production-like
@@ -125,6 +127,7 @@ export const SENTRY_DSN =
? ''
: process.env.SENTRY_DSN;
export const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || 'localhost';
export const COOKIE_SECRET = process.env.COOKIE_SECRET;
export const JWT_SECRET = process.env.JWT_SECRET;
export const SES_ID = process.env.SES_ID;
export const SES_SECRET = process.env.SES_SECRET;

7
api/src/utils/ids.ts Normal file
View File

@@ -0,0 +1,7 @@
import { customAlphabet } from 'nanoid';
// uppercase, lowercase letters and numbers
export const customNanoid = customAlphabet(
'1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
64
);

View File

@@ -1,7 +1,6 @@
import { FastifyRequest } from 'fastify';
import jwt from 'jsonwebtoken';
// import { allowedOrigins } from '../../config/allowed-origins';
import { availableLangs } from '../../../shared/config/i18n';
import { allowedOrigins } from './allowed-origins';

View File

@@ -0,0 +1,28 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createAccessToken } from './tokens';
describe('createAccessToken', () => {
it('creates an object with id, ttl, created and userId', () => {
const userId = 'abc';
const actual = createAccessToken(userId);
expect(actual).toStrictEqual({
id: expect.stringMatching(/[a-zA-Z0-9]{64}/),
ttl: 77760000000,
created: expect.stringMatching(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
),
userId
});
});
it('sets the ttl, defaulting to 77760000000 ms', () => {
const userId = 'abc';
const ttl = 123;
const actual = createAccessToken(userId, ttl);
expect(actual.ttl).toBe(ttl);
expect(createAccessToken(userId).ttl).toBe(77760000000);
});
});

39
api/src/utils/tokens.ts Normal file
View File

@@ -0,0 +1,39 @@
import jwt from 'jsonwebtoken';
import { customNanoid } from './ids';
import { JWT_SECRET } from './env';
/**
* Encode an id into a JWT (the naming suggests it's a user token, but it's the
* id of the UserToken document).
* @param userToken A token id to encode.
* @returns An encoded object with the userToken property.
*/
export function encodeUserToken(userToken: string): string {
return jwt.sign({ userToken }, JWT_SECRET);
}
export type AccessToken = {
userId: string;
id: string;
ttl: number;
created: string;
};
/**
* 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).
* @param ttl The time to live for the token in milliseconds (default: 77760000000).
* @returns The access token.
*/
export const createAccessToken = (
userId: string,
ttl?: number
): AccessToken => {
return {
userId,
id: customNanoid(),
ttl: ttl ?? 77760000000,
created: new Date().toISOString()
};
};

View File

@@ -1,13 +0,0 @@
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from './env';
/**
* Encode an id into a JWT (the naming suggests it's a user token, but it's the
* id of the UserToken document).
* @param userToken A token id to encode.
* @returns An encoded object with the userToken property.
*/
export function encodeUserToken(userToken: string): string {
return jwt.sign({ userToken }, JWT_SECRET);
}