Skip to content

Commit de83c40

Browse files
Merge pull request #705 from freeCodeCamp/main
Create a new pull request by comparing changes across two branches
2 parents 1e7c7b1 + 66d29c0 commit de83c40

File tree

130 files changed

+4512
-705
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

130 files changed

+4512
-705
lines changed

api/prisma/schema.prisma

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,9 @@ model Donation {
178178
}
179179

180180
model UserRateLimit {
181-
id String @id @default(auto()) @map("_id") @db.ObjectId
182-
/// Field referred in an index, but found no data to define the type.
183-
expirationDate Json?
181+
id String @id @map("_id")
182+
counter Int
183+
expirationDate DateTime @db.Date
184184
185185
@@index([expirationDate], map: "expirationDate_1")
186186
}

api/src/app.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import { SESProvider } from './plugins/mail-providers/ses';
2424
import mailer from './plugins/mailer';
2525
import redirectWithMessage from './plugins/redirect-with-message';
2626
import security from './plugins/security';
27-
import codeFlowAuth from './plugins/code-flow-auth';
27+
import auth from './plugins/auth';
28+
import bouncer from './plugins/bouncer';
2829
import notFound from './plugins/not-found';
2930
import { authRoutes, mobileAuth0Routes } from './routes/auth';
3031
import { devAuthRoutes } from './routes/auth-dev';
@@ -182,44 +183,46 @@ export const build = async (
182183

183184
// redirectWithMessage must be registered before codeFlowAuth
184185
void fastify.register(redirectWithMessage);
185-
void fastify.register(codeFlowAuth);
186+
void fastify.register(auth);
186187
void fastify.register(notFound);
187188
void fastify.register(prismaPlugin);
188189

189-
// Routes requiring authentication and CSRF protection
190-
void fastify.register(function (fastify, _opts, done) {
191-
// The order matters here, since we want to reject invalid cross site requests
192-
// before checking if the user is authenticated.
193-
// @ts-expect-error - @fastify/csrf-protection needs to update their types
194-
// eslint-disable-next-line @typescript-eslint/unbound-method
195-
fastify.addHook('onRequest', fastify.csrfProtection);
190+
// Routes requiring authentication:
191+
void fastify.register(async function (fastify, _opts) {
192+
await fastify.register(bouncer);
196193
fastify.addHook('onRequest', fastify.authorize);
194+
// CSRF protection enabled:
195+
await fastify.register(async function (fastify, _opts) {
196+
// TODO: bounce unauthed requests before checking CSRF token. This will
197+
// mean moving csrfProtection into custom plugin and testing separately,
198+
// because it's a pain to mess around with other cookies/hook order.
199+
// @ts-expect-error - @fastify/csrf-protection needs to update their types
200+
// eslint-disable-next-line @typescript-eslint/unbound-method
201+
fastify.addHook('onRequest', fastify.csrfProtection);
202+
fastify.addHook('onRequest', fastify.send401IfNoUser);
203+
204+
await fastify.register(challengeRoutes);
205+
await fastify.register(donateRoutes);
206+
await fastify.register(protectedCertificateRoutes);
207+
await fastify.register(settingRoutes);
208+
await fastify.register(userRoutes);
209+
});
197210

198-
void fastify.register(challengeRoutes);
199-
void fastify.register(donateRoutes);
200-
void fastify.register(protectedCertificateRoutes);
201-
void fastify.register(settingRoutes);
202-
void fastify.register(userRoutes);
203-
done();
204-
});
205-
206-
// Routes requiring authentication and NOT CSRF protection
207-
void fastify.register(function (fastify, _opts, done) {
208-
fastify.addHook('onRequest', fastify.authorize);
211+
// CSRF protection disabled:
212+
await fastify.register(async function (fastify, _opts) {
213+
fastify.addHook('onRequest', fastify.send401IfNoUser);
209214

210-
void fastify.register(userGetRoutes);
211-
done();
212-
});
215+
await fastify.register(userGetRoutes);
216+
});
213217

214-
// Routes requiring authentication that redirect on failure
215-
void fastify.register(function (fastify, _opts, done) {
216-
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
218+
// Routes that redirect if access is denied:
219+
await fastify.register(async function (fastify, _opts) {
220+
fastify.addHook('onRequest', fastify.redirectIfNoUser);
217221

218-
void fastify.register(settingRedirectRoutes);
219-
done();
222+
await fastify.register(settingRedirectRoutes);
223+
});
220224
});
221-
222-
// Routes not requiring authentication
225+
// Routes not requiring authentication:
223226
void fastify.register(mobileAuth0Routes);
224227
// TODO: consolidate with LOCAL_MOCK_AUTH
225228
if (FCC_ENABLE_DEV_LOGIN_MODE) {

api/src/plugins/auth.test.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import Fastify, { FastifyInstance } from 'fastify';
2+
import jwt from 'jsonwebtoken';
3+
4+
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
5+
import { type Token, createAccessToken } from '../utils/tokens';
6+
import cookies, { sign as signCookie, unsign as unsignCookie } from './cookies';
7+
import auth from './auth';
8+
9+
async function setupServer() {
10+
const fastify = Fastify();
11+
await fastify.register(cookies);
12+
await fastify.register(auth);
13+
return fastify;
14+
}
15+
16+
describe('auth', () => {
17+
let fastify: FastifyInstance;
18+
19+
beforeEach(async () => {
20+
fastify = await setupServer();
21+
});
22+
23+
afterEach(async () => {
24+
await fastify.close();
25+
});
26+
27+
describe('setAccessTokenCookie', () => {
28+
// We won't need to keep doubly signing the cookie when we migrate the
29+
// authentication, but for the MVP we have to be able to read the cookies
30+
// set by the api-server. So, double signing:
31+
it('should doubly sign the cookie', async () => {
32+
const token = createAccessToken('test-id');
33+
fastify.get('/test', async (req, reply) => {
34+
reply.setAccessTokenCookie(token);
35+
return { ok: true };
36+
});
37+
38+
const res = await fastify.inject({
39+
method: 'GET',
40+
url: '/test'
41+
});
42+
43+
const { value, ...rest } = res.cookies[0]!;
44+
const unsignedOnce = unsignCookie(value);
45+
const unsignedTwice = jwt.verify(unsignedOnce.value!, JWT_SECRET) as {
46+
accessToken: Token;
47+
};
48+
expect(unsignedTwice.accessToken).toEqual(token);
49+
expect(rest).toEqual({
50+
name: 'jwt_access_token',
51+
path: '/',
52+
sameSite: 'Lax',
53+
domain: COOKIE_DOMAIN,
54+
maxAge: token.ttl
55+
});
56+
});
57+
58+
// TODO: Post-MVP sync the cookie max-age with the token ttl (i.e. the
59+
// max-age should be the ttl/1000, not ttl)
60+
it('should set the max-age of the cookie to match the ttl of the token', async () => {
61+
const token = createAccessToken('test-id', 123000);
62+
fastify.get('/test', async (req, reply) => {
63+
reply.setAccessTokenCookie(token);
64+
return { ok: true };
65+
});
66+
67+
const res = await fastify.inject({
68+
method: 'GET',
69+
url: '/test'
70+
});
71+
72+
expect(res.cookies[0]).toEqual(
73+
expect.objectContaining({
74+
maxAge: 123000
75+
})
76+
);
77+
});
78+
});
79+
80+
describe('authorize', () => {
81+
beforeEach(() => {
82+
fastify.get('/test', (_req, reply) => {
83+
void reply.send({ ok: true });
84+
});
85+
fastify.addHook('onRequest', fastify.authorize);
86+
});
87+
88+
it('should deny if the access token is missing', async () => {
89+
expect.assertions(4);
90+
91+
fastify.addHook('onRequest', (req, _reply, done) => {
92+
expect(req.accessDeniedMessage).toEqual({
93+
type: 'info',
94+
content: 'Access token is required for this request'
95+
});
96+
expect(req.user).toBeNull();
97+
done();
98+
});
99+
100+
const res = await fastify.inject({
101+
method: 'GET',
102+
url: '/test'
103+
});
104+
105+
expect(res.json()).toEqual({ ok: true });
106+
expect(res.statusCode).toEqual(200);
107+
});
108+
109+
it('should deny if the access token is not signed', async () => {
110+
expect.assertions(4);
111+
112+
fastify.addHook('onRequest', (req, _reply, done) => {
113+
expect(req.accessDeniedMessage).toEqual({
114+
type: 'info',
115+
content: 'Access token is required for this request'
116+
});
117+
expect(req.user).toBeNull();
118+
done();
119+
});
120+
121+
const token = jwt.sign(
122+
{ accessToken: createAccessToken('123') },
123+
JWT_SECRET
124+
);
125+
const res = await fastify.inject({
126+
method: 'GET',
127+
url: '/test',
128+
cookies: {
129+
jwt_access_token: token
130+
}
131+
});
132+
133+
expect(res.json()).toEqual({ ok: true });
134+
expect(res.statusCode).toEqual(200);
135+
});
136+
137+
it('should deny if the access token is invalid', async () => {
138+
expect.assertions(4);
139+
140+
fastify.addHook('onRequest', (req, _reply, done) => {
141+
expect(req.accessDeniedMessage).toEqual({
142+
type: 'info',
143+
content: 'Your access token is invalid'
144+
});
145+
expect(req.user).toBeNull();
146+
done();
147+
});
148+
149+
const token = jwt.sign(
150+
{ accessToken: createAccessToken('123') },
151+
'invalid-secret'
152+
);
153+
154+
const res = await fastify.inject({
155+
method: 'GET',
156+
url: '/test',
157+
cookies: {
158+
jwt_access_token: signCookie(token)
159+
}
160+
});
161+
162+
expect(res.json()).toEqual({ ok: true });
163+
expect(res.statusCode).toEqual(200);
164+
});
165+
166+
it('should deny if the access token has expired', async () => {
167+
expect.assertions(4);
168+
169+
fastify.addHook('onRequest', (req, _reply, done) => {
170+
expect(req.accessDeniedMessage).toEqual({
171+
type: 'info',
172+
content: 'Access token is no longer valid'
173+
});
174+
expect(req.user).toBeNull();
175+
done();
176+
});
177+
178+
const token = jwt.sign(
179+
{ accessToken: createAccessToken('123', -1) },
180+
JWT_SECRET
181+
);
182+
183+
const res = await fastify.inject({
184+
method: 'GET',
185+
url: '/test',
186+
cookies: {
187+
jwt_access_token: signCookie(token)
188+
}
189+
});
190+
191+
expect(res.json()).toEqual({ ok: true });
192+
expect(res.statusCode).toEqual(200);
193+
});
194+
195+
it('should deny if the user is not found', async () => {
196+
expect.assertions(4);
197+
198+
fastify.addHook('onRequest', (req, _reply, done) => {
199+
expect(req.accessDeniedMessage).toEqual({
200+
type: 'info',
201+
content: 'Your access token is invalid'
202+
});
203+
expect(req.user).toBeNull();
204+
done();
205+
});
206+
207+
// @ts-expect-error prisma isn't defined, since we're not building the
208+
// full application here.
209+
fastify.prisma = { user: { findUnique: () => null } };
210+
const token = jwt.sign(
211+
{ accessToken: createAccessToken('123') },
212+
JWT_SECRET
213+
);
214+
215+
const res = await fastify.inject({
216+
method: 'GET',
217+
url: '/test',
218+
cookies: {
219+
jwt_access_token: signCookie(token)
220+
}
221+
});
222+
223+
expect(res.json()).toEqual({ ok: true });
224+
expect(res.statusCode).toEqual(200);
225+
});
226+
227+
it('should populate the request with the user if the token is valid', async () => {
228+
const fakeUser = { id: '123', username: 'test-user' };
229+
// @ts-expect-error prisma isn't defined, since we're not building the
230+
// full application here.
231+
fastify.prisma = { user: { findUnique: () => fakeUser } };
232+
fastify.get('/test-user', req => {
233+
expect(req.user).toEqual(fakeUser);
234+
return { ok: true };
235+
});
236+
237+
const token = jwt.sign(
238+
{ accessToken: createAccessToken('123') },
239+
JWT_SECRET
240+
);
241+
const res = await fastify.inject({
242+
method: 'GET',
243+
url: '/test-user',
244+
cookies: {
245+
jwt_access_token: signCookie(token)
246+
}
247+
});
248+
249+
expect(res.json()).toEqual({ ok: true });
250+
expect(res.statusCode).toEqual(200);
251+
});
252+
});
253+
});

0 commit comments

Comments
 (0)