Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1/* eslint-disable max-lines */
2// In this case, we want to rely on apollo-server-express bundling a
3// corresponding version of apollo-server-core, rather than picking an
4// apollo-server-core version in package.json
5// eslint-disable-next-line import/no-extraneous-dependencies
6import os from 'node:os';
7import path from 'path';
8import { makeExecutableSchema } from '@graphql-tools/schema';
9import { MapperKind, mapSchema } from '@graphql-tools/utils';
10import { SpanStatusCode } from '@opentelemetry/api';
11import {
12 SEMATTRS_EXCEPTION_MESSAGE,
13 SEMATTRS_EXCEPTION_STACKTRACE,
14 SEMATTRS_EXCEPTION_TYPE,
15} from '@opentelemetry/semantic-conventions';
16import {
17 ApolloError,
18 ApolloServerPluginLandingPageDisabled,
19 ApolloServerPluginLandingPageGraphQLPlayground,
20} from 'apollo-server-core';
21import { ApolloServer } from 'apollo-server-express';
22import connectPgSimple from 'connect-pg-simple';
23import cors from 'cors';
24import express, { type ErrorRequestHandler } from 'express';
25import session from 'express-session';
26import { buildContext, GraphQLLocalStrategy } from 'graphql-passport';
27import depthLimit from 'graphql-depth-limit';
28import helmet from 'helmet';
29import passport from 'passport';
30import { MultiSamlStrategy } from '@node-saml/passport-saml';
31
32import {
33 makeLoginIncorrectPasswordError,
34 makeLoginSsoRequiredError,
35 makeLoginUserDoesNotExistError,
36} from './graphql/datasources/UserApi.js';
37import resolvers from './graphql/resolvers.js';
38import typeDefs from './graphql/schema.js';
39import { authSchemaWrapper } from './graphql/utils/authorization.js';
40import { type Dependencies } from './iocContainer/index.js';
41import { safeGetEnvInt } from './iocContainer/utils.js';
42import controllers from './routes/index.js';
43import { jsonStringify } from './utils/encoding.js';
44import {
45 ErrorType,
46 getErrorsFromAggregateError,
47 makeBadRequestError,
48 makeInternalServerError,
49 makeNotFoundError,
50 sanitizeError,
51 type SerializableError,
52} from './utils/errors.js';
53import { safePick } from './utils/misc.js';
54import {
55 isNonEmptyArray,
56 type NonEmptyArray,
57} from './utils/typescript-types.js';
58
59function getCPUInfo() {
60 const cpus = os.cpus();
61
62 const total = cpus.reduce(
63 (acc, cpu) =>
64 acc +
65 cpu.times.user +
66 cpu.times.nice +
67 cpu.times.sys +
68 cpu.times.irq +
69 cpu.times.idle,
70 0,
71 );
72 const idle = cpus.reduce((acc, cpu) => acc + cpu.times.idle, 0);
73
74 return {
75 idle,
76 total,
77 };
78}
79
80async function getCPUUsage() {
81 const stats1 = getCPUInfo();
82 const startIdle = stats1.idle;
83 const startTotal = stats1.total;
84 await new Promise((resolve) => setTimeout(resolve, 1000));
85
86 const stats2 = getCPUInfo();
87 const endIdle = stats2.idle;
88 const endTotal = stats2.total;
89 return 1 - (endIdle - startIdle) / (endTotal - startTotal);
90}
91
92// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
93const env = process.env.NODE_ENV || 'development';
94const sessionStore = connectPgSimple(session);
95
96export default async function makeApiServer(deps: Dependencies) {
97 const app = express();
98 const { User } = deps.Sequelize;
99
100 app.use(cors());
101
102 app.use(
103 helmet(
104 env === 'production'
105 ? {}
106 : {
107 contentSecurityPolicy: {
108 directives: {
109 defaultSrc: ["'self'"],
110 scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
111 styleSrc: ["'self'", "'unsafe-inline'"],
112 imgSrc: ["'self'", 'data:', 'blob:', 'https:', 'http:'],
113 connectSrc: ["'self'", 'ws:', 'wss:', 'https:', 'http:'],
114 fontSrc: ["'self'", 'data:', 'https:'],
115 frameSrc: ["'self'"],
116 },
117 },
118 },
119 ),
120 );
121 app.use(express.json({ limit: '50mb' }));
122
123 app.get('/ready', async (_req, res) => {
124 const cpuUsage = await getCPUUsage();
125 if (cpuUsage > 0.75) {
126 return res.status(500).send('Unhealthy');
127 }
128 return res.status(200).send('Healthy');
129 });
130
131 /**
132 * Passport & User Session Configuration
133 */
134 const {
135 DATABASE_HOST,
136 DATABASE_PORT = 5432,
137 DATABASE_NAME,
138 DATABASE_USER,
139 DATABASE_PASSWORD,
140 } = process.env;
141
142 const connectionString = `postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}`;
143
144 app.use(
145 session({
146 secret: process.env.SESSION_SECRET!,
147 store: new sessionStore({ conString: connectionString }),
148 cookie: {
149 secure: process.env.NODE_ENV === 'production',
150 httpOnly: true,
151 sameSite: 'lax',
152 // 30 Days in milliseconds
153 maxAge: 30 * 24 * 60 * 60 * 1000,
154 },
155 resave: false,
156 saveUninitialized: false,
157 proxy: true,
158 }),
159 );
160 app.use(passport.initialize());
161 app.use(passport.session());
162
163 passport.use(
164 new MultiSamlStrategy(
165 {
166 passReqToCallback: true,
167 async getSamlOptions(req, done) {
168 // orgId path param should be set in the /saml/* route handlers
169 const orgId = req.params['orgId'];
170
171 if (!orgId) {
172 return done(
173 makeNotFoundError('orgId not found in path.', {
174 shouldErrorSpan: true,
175 }),
176 );
177 }
178
179 const samlSettings = await deps.OrgSettingsService.getSamlSettings(
180 orgId,
181 );
182
183 if (!samlSettings)
184 return done(
185 makeInternalServerError('Unexpected error.', {
186 shouldErrorSpan: true,
187 }),
188 );
189
190 if (!samlSettings.saml_enabled)
191 return done(
192 makeBadRequestError('SAML not enabled for this organization.', {
193 shouldErrorSpan: true,
194 }),
195 );
196
197 done(null, {
198 entryPoint: samlSettings.sso_url as string,
199 idpCert: samlSettings.cert as string,
200 // I could use UI_URL here but technically the API could be hosted
201 // on a different domain in the future so hopefully this is more
202 // robust, not that it will likely matter.
203 callbackUrl: `${deps.ConfigService.uiUrl}/api/v1/saml/login/${orgId}/callback`,
204 issuer: deps.ConfigService.uiUrl,
205 });
206 },
207 },
208 async (_req, profile, done) => {
209 try {
210 const user = await User.findOne({
211 where: { email: String(profile?.email) },
212 });
213 // we should have already checked for this, but couldn't hurt to check
214 // again
215 if (user == null) {
216 return done(
217 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }),
218 );
219 }
220
221 return done(null, user as any);
222 } catch (e) {
223 return done(
224 makeInternalServerError('Unknown error during login attempt', {
225 shouldErrorSpan: true,
226 }),
227 );
228 }
229 },
230 async (_req, profile, done) => {
231 try {
232 const user = await User.findOne({
233 where: { email: String(profile?.email) },
234 });
235 // we should have already checked for this, but couldn't hurt to check
236 // again
237 if (user == null) {
238 return done(
239 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }),
240 );
241 }
242
243 return done(null, user as any);
244 } catch (e) {
245 return done(
246 makeInternalServerError('Unknown error during login attempt', {
247 shouldErrorSpan: true,
248 }),
249 );
250 }
251 },
252 ),
253 );
254
255 app.get(
256 '/saml/login/:orgId',
257 passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }),
258 );
259
260 app.post(
261 `/saml/login/:orgId/callback`,
262 express.urlencoded({ extended: false }),
263 passport.authenticate('saml', {
264 failureRedirect: '/',
265 failureFlash: true,
266 }),
267 (_req, res) => {
268 res.redirect(`${deps.ConfigService.uiUrl}/dashboard`);
269 },
270 );
271
272 passport.use(
273 new GraphQLLocalStrategy(async (email, password, done) => {
274 try {
275 const user = await User.findOne({ where: { email: String(email) } });
276 if (user == null) {
277 return done(
278 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }),
279 );
280 }
281 const samlSettings = await deps.OrgSettingsService.getSamlSettings(
282 user.orgId,
283 );
284
285 if (
286 samlSettings?.saml_enabled &&
287 // We allow Coop users to log in with email/password even if SSO is
288 // enabled
289 // so Coop employees can manage user accounts
290 String(email).split('@')[1] !== 'getcoop.com'
291 ) {
292 return done(
293 makeLoginSsoRequiredError({
294 detail:
295 'SAML is enabled for this organization. Password login is disabled.',
296 shouldErrorSpan: true,
297 }),
298 );
299 }
300
301 if (!user.loginMethods.includes('password')) {
302 return done(
303 makeLoginIncorrectPasswordError({
304 detail: 'Password is not set for user.',
305 shouldErrorSpan: true,
306 }),
307 );
308 }
309
310 // if loginMethod is password, password should be set
311 if (
312 await User.passwordMatchesHash(
313 String(password),
314 user.password satisfies string | null as string,
315 )
316 ) {
317 done(null, user);
318 } else {
319 done(makeLoginIncorrectPasswordError({ shouldErrorSpan: true }));
320 }
321 } catch (e) {
322 deps.Tracer.logActiveSpanFailedIfAny(e);
323 return done(
324 makeInternalServerError('Unknown error during login attempt', {
325 shouldErrorSpan: true,
326 }),
327 );
328 }
329 }),
330 );
331
332 passport.serializeUser((user: any, done) => {
333 done(null, user.id);
334 });
335
336 passport.deserializeUser(async (id, done) => {
337 return User.findByPk(String(id), { rejectOnEmpty: true }).then((user) => {
338 done(null, user);
339 }, done);
340 });
341
342 /**
343 * Apollo Server - uses /api/graphql path
344 */
345 const apolloServer = new ApolloServer({
346 schema: mapSchema(makeExecutableSchema({ typeDefs, resolvers }), {
347 [MapperKind.QUERY_ROOT_FIELD](
348 fieldConfig,
349 _fieldName,
350 _typeName,
351 schema,
352 ) {
353 return authSchemaWrapper(fieldConfig, schema);
354 },
355 [MapperKind.MUTATION_ROOT_FIELD](
356 fieldConfig,
357 _fieldName,
358 _typeName,
359 schema,
360 ) {
361 return authSchemaWrapper(fieldConfig, schema);
362 },
363 }),
364 dataSources: () => deps.DataSources,
365 context: ({ req, res }) => {
366 return {
367 ...buildContext({ req, res }),
368 services: makeGqlServices(deps),
369 };
370 },
371 plugins: [
372 {
373 ...(process.env.NODE_ENV === 'production'
374 ? ApolloServerPluginLandingPageDisabled()
375 : ApolloServerPluginLandingPageGraphQLPlayground()),
376 },
377 ],
378 validationRules: [depthLimit(safeGetEnvInt('GRAPHQL_MAX_DEPTH', 10))],
379 introspection: process.env.NODE_ENV !== 'production',
380 formatError(e) {
381 // `e` can be an ApolloError instance, but will only be one if such an
382 // instance (or an ApolloError subclass) was explicitly thrown from a
383 // resolver. In that case, we assume the thrower knows they're dealing
384 // with apollo, and we can just pass the error through as-is.
385 if (e instanceof ApolloError) {
386 return e;
387 }
388
389 // In almost all other cases, the error will be an instance of the
390 // `GraphQLError` class, which apollo instantiates automatically, and uses
391 // to wrap any non-ApolloError error thrown from a resolver. However,
392 // ocassionally -- e.g., if an error occurs during context creation rather
393 // than in the resolver -- the error doesn't get wrapped (or it's wrapped
394 // but with no originalError), so we handle both cases. Once we have the
395 // underlying error that was actually thrown, we sanitize it to remove
396 // sensitive details, and then try to format it in the most informative
397 // way possible.
398 const sanitizedError = sanitizeError(e.originalError ?? e);
399 const { title: sanitizedErrorTitle, ...extensions } = sanitizedError;
400
401 return {
402 // When apollo-server wraps the resolver-thrown error in a GraphQLError,
403 // it automatically tracks some metadata about where the error was thrown
404 // from. That can be useful to clients, in a way that's a bit different
405 // from our CoopError.pointer field; it tells them whether a null
406 // value was return in the response because a given resolver failed, or
407 // because the field's value is actually null. So, we pass this
408 // apollo-annotated metdata through as-is.
409 locations: e.locations,
410 path: e.path,
411 // Apollo server also defines some predefined error codes that it could
412 // be helpful for us to mimic on our custom errors (in case Apollo
413 // clients handle them out of the box). The true, Coop-assigned code
414 // for the error, though, will be in the `type` key, just like when
415 // sending errors in REST responses (though, for GQL, this lives under
416 // `extensions`).
417 code: extensions.type.includes(ErrorType.Unauthenticated)
418 ? 'UNAUTHENTICATED'
419 : extensions.type.includes(ErrorType.Unauthorized)
420 ? 'FORBIDDEN'
421 : extensions.type.includes(ErrorType.InvalidUserInput)
422 ? 'BAD_USER_INPUT'
423 : 'INTERNAL_SERVER_ERROR',
424 // Then, this is info from the sanitized verion of the actual thrown error.
425 message: sanitizedErrorTitle,
426 extensions,
427 };
428 },
429 });
430
431 await apolloServer.start().then(() => {
432 apolloServer.applyMiddleware({ app });
433 Object.entries(controllers).forEach(([_k, controller]) => {
434 controller.routes.forEach((it) => {
435 const handler = it.handler(deps);
436 app[it.method](
437 path.join(controller.pathPrefix, it.path),
438 ...(Array.isArray(handler) ? handler : [handler]),
439 );
440 });
441 });
442
443 // catch 404 and forward to error handler
444 app.use(function (_req, _res, next) {
445 next(
446 makeNotFoundError('Requested route not found.', {
447 shouldErrorSpan: true,
448 }),
449 );
450 });
451
452 // error handler
453 app.use(async function (err, _req, res, _next) {
454 await deps.Tracer.addActiveSpan(
455 { resource: 'app', operation: 'handleError' },
456 async (span) => {
457 span.recordException(err);
458 span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
459
460 // I don't know if these attributes are necessary, with recordException
461 span.setAttribute(SEMATTRS_EXCEPTION_MESSAGE, err.message);
462 if (err.stack) {
463 span.setAttribute(SEMATTRS_EXCEPTION_STACKTRACE, err.stack);
464 }
465 span.setAttribute(SEMATTRS_EXCEPTION_TYPE, err.name);
466
467 const errors = (() => {
468 if (err instanceof AggregateError) {
469 const extractedErrors = getErrorsFromAggregateError(err);
470 return isNonEmptyArray(extractedErrors) ? extractedErrors : [err];
471 } else {
472 return [err];
473 }
474 })() satisfies NonEmptyArray<unknown>;
475
476 // If we had any nested errors (from an AggregateError),
477 // attach those to the span too.
478 if (errors.length > 1 || errors[0] !== err) {
479 span.setAttribute(
480 'errors',
481 jsonStringify(
482 errors.map((it) => safePick(it, ['name', 'message', 'stack'])),
483 ),
484 );
485 }
486
487 // If we've already sent response headers or the response status code,
488 // we can't actually send a different status code here: it's an error
489 // in HTTP to send the headers portion of a response twice. So, we
490 // need to skip this step.
491 //
492 // This can happen, e.g., if we have a request handler that
493 // immediately responds with a 202/204 but then continues to do some
494 // processing work in the background, and that work errors.
495 if (!res.headersSent) {
496 const safeErrors = errors.map((it) =>
497 sanitizeError(it),
498 ) satisfies SerializableError[] as NonEmptyArray<SerializableError>;
499
500 res.status(pickStatus(safeErrors)).json({ errors: safeErrors });
501 }
502 },
503 );
504 } as ErrorRequestHandler);
505 });
506
507 return {
508 app,
509 async shutdown() {
510 await Promise.all([
511 apolloServer.stop(),
512 deps.closeSharedResourcesForShutdown(),
513 ]);
514 },
515 };
516}
517
518function pickStatus(safeErrors: NonEmptyArray<SerializableError>) {
519 return safeErrors[0].status;
520}
521
522function makeGqlServices(deps: Dependencies) {
523 return {
524 ...safePick(deps, [
525 'ApiKeyService',
526 'DataWarehouse',
527 'DerivedFieldsService',
528 'getItemTypeEventuallyConsistent',
529 'getEnabledRulesForItemTypeEventuallyConsistent',
530 'ItemInvestigationService',
531 'ModerationConfigService',
532 'ManualReviewToolService',
533 'HMAHashBankService',
534 'NcmecService',
535 'OrgSettingsService',
536 'PartialItemsService',
537 'ReportingService',
538 'RuleEvaluator',
539 'Sequelize',
540 'SignalsService',
541 'SigningKeyPairService',
542 'Tracer',
543 'UserManagementService',
544 'UserStatisticsService',
545 'UserHistoryQueries',
546 'UserStrikeService',
547 'SSOService',
548 ]),
549 // Calling sendEmail straight from a resolver is hella sketch, as the
550 // resolvers shouldn’t have real business logic in them. Future sendEmail
551 // calls should be encapsulated inside some business-logic-containing
552 // service, and it’s that service that should be called from the resolvers.
553 legacy_DO_NOT_USE_DIRECTLY_sendEmail: deps.sendEmail,
554 };
555}
556
557export type GQLServices = ReturnType<typeof makeGqlServices>;