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