diff --git a/eslint.config.js b/eslint.config.js
index d5ae4fa3..c6a4d5b3 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -249,6 +249,8 @@ export default eslintTs.config(
"@typescript-eslint/no-misused-promises": "off",
// Disabled for performance
"@typescript-eslint/no-unsafe-assignment": "off",
+ // Disabled for performance
+ "@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/return-await": "error",
"@typescript-eslint/consistent-type-imports": [
diff --git a/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx b/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx
index 4b4c9b9f..a9edfc7b 100644
--- a/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx
+++ b/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx
@@ -1,4 +1,3 @@
-/* eslint-disable react-refresh/only-export-components */
import random from "lodash/random";
import type { ImageSourcePropType } from "react-native";
diff --git a/packages/mobile/src/theme/components.ts b/packages/mobile/src/theme/components.ts
index a3a8deab..0ac552da 100644
--- a/packages/mobile/src/theme/components.ts
+++ b/packages/mobile/src/theme/components.ts
@@ -1,4 +1,6 @@
+/* eslint-disable */
import type { ComponentTheme, Theme } from "native-base";
+// @ts-expect-error Broken module
import originalComponentThemes from "native-base/components";
const {
diff --git a/packages/portal/gql/graphql-env.d.ts b/packages/portal/gql/graphql-env.d.ts
index 3208e551..1b5c1fe0 100644
--- a/packages/portal/gql/graphql-env.d.ts
+++ b/packages/portal/gql/graphql-env.d.ts
@@ -190,7 +190,7 @@ export type introspection_types = {
'String': unknown;
'TeamLegacyStatus': { name: 'TeamLegacyStatus'; enumValues: 'DemoTeam' | 'NewTeam' | 'ReturningTeam'; };
'TeamNode': { kind: 'OBJECT'; name: 'TeamNode'; fields: { 'committeeIdentifier': { name: 'committeeIdentifier'; type: { kind: 'ENUM'; name: 'CommitteeIdentifier'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'LuxonDateTime'; ofType: null; } }; 'fundraisingEntries': { name: 'fundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; }; } }; 'fundraisingTotalAmount': { name: 'fundraisingTotalAmount'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'legacyStatus': { name: 'legacyStatus'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamLegacyStatus'; ofType: null; }; } }; 'marathon': { name: 'marathon'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'members': { name: 'members'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'pointEntries': { name: 'pointEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointEntryNode'; ofType: null; }; }; }; } }; 'solicitationCode': { name: 'solicitationCode'; type: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; } }; 'totalPoints': { name: 'totalPoints'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamType'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'LuxonDateTime'; ofType: null; } }; }; };
- 'TeamResolverFilterFields': { name: 'TeamResolverFilterFields'; enumValues: 'legacyStatus' | 'marathonYear' | 'name' | 'type'; };
+ 'TeamResolverFilterFields': { name: 'TeamResolverFilterFields'; enumValues: 'legacyStatus' | 'marathonYear' | 'name' | 'totalPoints' | 'type'; };
'TeamResolverFilterGroup': { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterGroup'; isOneOf: false; inputFields: [{ name: 'children'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterGroup'; ofType: null; }; }; }; }; defaultValue: "[]" }, { name: 'filters'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterItem'; ofType: null; }; }; }; }; defaultValue: "[]" }, { name: 'operator'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'FilterGroupOperator'; ofType: null; }; }; defaultValue: null }]; };
'TeamResolverFilterItem': { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterItem'; isOneOf: false; inputFields: [{ name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamResolverFilterFields'; ofType: null; }; }; defaultValue: null }, { name: 'filter'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'SomeFilter'; ofType: null; }; }; defaultValue: null }]; };
'TeamResolverSearchFilter': { kind: 'INPUT_OBJECT'; name: 'TeamResolverSearchFilter'; isOneOf: false; inputFields: [{ name: 'fields'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamResolverFilterFields'; ofType: null; }; }; }; defaultValue: null }, { name: 'query'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; }; defaultValue: null }]; };
diff --git a/packages/portal/src/config/refine/resources.tsx b/packages/portal/src/config/refine/resources.tsx
index 266e57f5..1d34d17d 100644
--- a/packages/portal/src/config/refine/resources.tsx
+++ b/packages/portal/src/config/refine/resources.tsx
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/no-base-to-string */
import {
BellOutlined,
CalendarOutlined,
diff --git a/packages/portal/src/elements/components/sider/index.tsx b/packages/portal/src/elements/components/sider/index.tsx
index fa766e3d..58ba94f4 100644
--- a/packages/portal/src/elements/components/sider/index.tsx
+++ b/packages/portal/src/elements/components/sider/index.tsx
@@ -97,7 +97,6 @@ export const Sider: React.FC<
},
label: (
<>
- {/* eslint-disable-next-line @typescript-eslint/no-base-to-string */}
{meta?.label}
{!menuCollapsed && isSelected && (
diff --git a/packages/server/prisma/migrations/20250116143217_/migration.sql b/packages/server/prisma/migrations/20250116143217_/migration.sql
new file mode 100644
index 00000000..58267c2f
--- /dev/null
+++ b/packages/server/prisma/migrations/20250116143217_/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Session" ALTER COLUMN "personId" DROP NOT NULL;
diff --git a/packages/server/prisma/schema/person.prisma b/packages/server/prisma/schema/person.prisma
index 54f3c83e..33a185ed 100644
--- a/packages/server/prisma/schema/person.prisma
+++ b/packages/server/prisma/schema/person.prisma
@@ -69,8 +69,8 @@ model Session {
ip String?
expiresAt DateTime
- personId Int
- person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
+ personId Int?
+ person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
}
enum AuthSource {
diff --git a/packages/server/src/jobs/garbageCollectLogins.ts b/packages/server/src/jobs/garbageCollectLogins.ts
index bbf487e4..5365abac 100644
--- a/packages/server/src/jobs/garbageCollectLogins.ts
+++ b/packages/server/src/jobs/garbageCollectLogins.ts
@@ -4,6 +4,7 @@ import { Cron } from "croner";
import { logger } from "#logging/standardLogging.js";
import { JobStateRepository } from "#repositories/JobState.js";
import { LoginFlowRepository } from "#repositories/LoginFlowSession.js";
+import { SessionRepository } from "#repositories/Session.js";
const jobStateRepository = Container.get(JobStateRepository);
@@ -25,6 +26,10 @@ export const garbageCollectLoginFlowSessions = new Cron(
logger.info("Garbage collecting old login flows");
const loginFlowSessionRepository = Container.get(LoginFlowRepository);
await loginFlowSessionRepository.gcOldLoginFlows();
+ const sessionRepository = Container.get(SessionRepository);
+ await sessionRepository
+ .gcOldSessions()
+ .promise.then((res) => res.unwrap());
await jobStateRepository.logCompletedJob(garbageCollectLoginFlowSessions);
} catch (error) {
diff --git a/packages/server/src/lib/auth/context.ts b/packages/server/src/lib/auth/context.ts
index 7e8bda38..52d90cc5 100644
--- a/packages/server/src/lib/auth/context.ts
+++ b/packages/server/src/lib/auth/context.ts
@@ -1,7 +1,7 @@
import type { ContextFunction } from "@apollo/server";
import type { ExpressContextFunctionArgument } from "@apollo/server/express4";
import { Container } from "@freshgum/typedi";
-import { CommitteeRole } from "@prisma/client";
+import { CommitteeRole, type Person, type Session } from "@prisma/client";
import type {
AppAbility,
AuthorizationContext,
@@ -25,11 +25,10 @@ import { superAdminLinkbluesToken } from "#lib/typediTokens.js";
import { personModelToResource } from "#repositories/person/personModelToResource.js";
import { PersonRepository } from "#repositories/person/PersonRepository.js";
-import { parseUserJwt } from "./index.js";
-
export interface GraphQLContext extends AuthorizationContext {
serverUrl: URL;
ability: AppAbility;
+ session: Session | null;
}
type UserContext = Partial<
@@ -39,28 +38,15 @@ type UserContext = Partial<
>
>;
async function getUserInfo(
- userId: string
+ person: Person
): Promise> {
const outputContext: UserContext = {};
const personRepository = Container.get(PersonRepository);
- const person = await personRepository.findPersonAndTeamsByUnique({
- uuid: userId,
- });
-
- if (person.isErr()) {
- if (person.error.tag === ErrorCode.NotFound) {
- // Short-circuit if the user is not found
- return Ok(outputContext);
- }
- return person;
- }
// If we found a user, set the authenticated user
// Convert the user to a resource and set it on the context
- const personResource = await personModelToResource(
- person.value,
- personRepository
- ).promise;
+ const personResource = await personModelToResource(person, personRepository)
+ .promise;
if (personResource.isErr()) {
return personResource;
}
@@ -70,7 +56,7 @@ async function getUserInfo(
// Set the teams the user is on
let teamMemberships = await personRepository.findMembershipsOfPerson(
{
- id: person.value.id,
+ id: person.id,
},
{},
undefined,
@@ -96,7 +82,7 @@ async function getUserInfo(
// Set the effective committee roles the user has
const effectiveCommitteeRoles =
await personRepository.getEffectiveCommitteeRolesOfPerson({
- id: person.value.id,
+ id: person.id,
});
if (effectiveCommitteeRoles.isErr()) {
return effectiveCommitteeRoles;
@@ -128,15 +114,18 @@ export const authenticate: ContextFunction<
token = authorizationHeader.substring("Bearer ".length);
}
}
- let userId: string | undefined;
+ let person: Person | null = null;
let authSource: AuthSource = AuthSource.None;
if (token) {
- ({ userId, authSource } = parseUserJwt(token));
+ ({ person, authSource } = req.session ?? {
+ authSource: AuthSource.None,
+ person: null,
+ });
}
let userContext: UserContext | undefined = undefined;
- if (userId) {
- const userInfo = await getUserInfo(userId);
+ if (person) {
+ const userInfo = await getUserInfo(person);
if (userInfo.isErr()) {
logger.error(`Failed to get user info: ${userInfo.error.toString()}`);
} else {
@@ -163,36 +152,30 @@ export const authenticate: ContextFunction<
logger.trace(
`graphqlContextFunction Masquerading as ${parsedId.value.id}`
);
- // We need to reset the dbRole to the default one in case the masquerade user is not a committee member
- const masqueradeUserInfo = await getUserInfo(parsedId.value.id);
- if (masqueradeUserInfo.isErr()) {
+ const person = await req
+ .getService(PersonRepository)
+ .findPersonByUnique({ uuid: parsedId.value.id });
+ if (person.isErr()) {
logger.error(
- `Failed to get masquerade user info: ${masqueradeUserInfo.error.toString()}`
+ `Failed to get masquerade user: ${person.error.toString()}`
);
+ } else if (person.value.isNone()) {
+ logger.error(`Failed to get masquerade user: not found`);
} else {
- userContext = masqueradeUserInfo.value;
+ const userInfo = await getUserInfo(person.value.value);
+ if (userInfo.isErr()) {
+ logger.error(
+ `Failed to get masquerade user info: ${userInfo.error.toString()}`
+ );
+ } else {
+ userContext = userInfo.value;
+ }
}
superAdmin = false;
}
}
- if (
- superAdmin &&
- (!userContext?.effectiveCommitteeRoles ||
- userContext.effectiveCommitteeRoles.length === 0)
- ) {
- userContext = {
- ...userContext,
- effectiveCommitteeRoles: [
- {
- identifier: CommitteeIdentifier.techCommittee,
- role: CommitteeRole.Chair,
- },
- ],
- };
- }
-
const authorizationContext: AuthorizationContext = {
authenticatedUser: userContext?.authenticatedUser ?? null,
effectiveCommitteeRoles: userContext?.effectiveCommitteeRoles ?? [],
@@ -215,6 +198,7 @@ export const authenticate: ContextFunction<
teamMemberships: authorizationContext.teamMemberships,
userId: userContext?.authenticatedUser?.id.id ?? null,
}),
+ session: req.session,
};
};
diff --git a/packages/server/src/lib/auth/index.ts b/packages/server/src/lib/auth/index.ts
deleted file mode 100644
index 734598de..00000000
--- a/packages/server/src/lib/auth/index.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import { Container } from "@freshgum/typedi";
-import type { JwtPayload, UserData } from "@ukdanceblue/common";
-import { AuthSource } from "@ukdanceblue/common";
-import type { Request } from "express";
-import jsonwebtoken from "jsonwebtoken";
-
-import { jwtSecretToken } from "#lib/typediTokens.js";
-
-const jwtSecret = Container.get(jwtSecretToken);
-const jwtIssuer = "https://app.danceblue.org";
-
-/**
- * @param payload The payload to check
- * @return Whether the payload is a valid JWT payload
- */
-export function isValidJwtPayload(payload: unknown): payload is JwtPayload {
- if (typeof payload !== "object" || payload === null) {
- return false;
- }
- const { sub, auth_source } = payload as Record;
- if (sub !== undefined && typeof sub !== "string") {
- return false;
- }
- if (
- ![...Object.values(AuthSource), "UkyLinkblue"].includes(
- auth_source as AuthSource
- )
- ) {
- return false;
- }
- return true;
-}
-
-/**
- * Mints a JWT for the given user data
- *
- * @param user The user data to mint a JWT for
- * @param source The source of the user's authorization
- * @return The JWT, containing the user's authorization data
- */
-export function makeUserJwt(user: UserData): string {
- const payload: JwtPayload = {
- auth_source:
- // Handle legacy "UkyLinkblue" auth source, can remove eventually
- (user.authSource as string) === "UkyLinkblue"
- ? AuthSource.LinkBlue
- : user.authSource,
- };
-
- if (user.userId) {
- payload.sub = user.userId;
- }
-
- return jsonwebtoken.sign(payload, jwtSecret, {
- issuer: jwtIssuer,
- // TODO: Set expiration
- });
-}
-
-/**
- * Parses a JWT into user data
- *
- * @param token The JWT to parse
- * @return The user data contained in the JWT
- */
-export function parseUserJwt(token: string): UserData {
- if (!jwtSecret) {
- throw new Error("JWT_SECRET is not set");
- }
-
- const payload = jsonwebtoken.verify(token, jwtSecret, {
- issuer: jwtIssuer,
- });
-
- if (!isValidJwtPayload(payload)) {
- throw new Error("Invalid JWT payload");
- }
-
- const userData: UserData = {
- authSource: payload.auth_source,
- };
-
- if (payload.sub) {
- userData.userId = payload.sub;
- }
-
- return userData;
-}
-
-/**
- * Parses a JWT from a request
- *
- * @param req The request to parse the JWT from
- * @return The JWT, or undefined if no JWT was found and any error messages
- */
-export function tokenFromRequest(
- req: Request
-): [string | undefined, "invalid-header" | "not-bearer" | "unknown" | null] {
- try {
- // Prefer cookie
- let jsonWebToken: string | undefined = undefined;
- const cookies = req.cookies as unknown;
- if (
- typeof cookies === "object" &&
- cookies &&
- typeof (cookies as { token: unknown }).token === "string"
- ) {
- jsonWebToken = (cookies as { token: string }).token;
- }
-
- // Fallback to header
- const authHeader = req.headers.authorization;
- if (authHeader) {
- const headerParts = authHeader.split(" ");
- if (headerParts.length !== 2) {
- return [undefined, "invalid-header"];
- }
- const authType = headerParts[0];
- jsonWebToken = headerParts[1];
-
- if (authType !== "Bearer") {
- return [undefined, "not-bearer"];
- }
- }
-
- return [jsonWebToken, null];
- } catch {
- return [undefined, "unknown"];
- }
-}
-
-/**
- * Checks the token from a request and returns an message about it's status
- *
- * @param req The request to check the token from
- * @return A message about the status of the token
- */
-export function checkTokenFromRequest(
- req: Request
-): [
- code: undefined | "invalid_request" | "invalid_token" | "no_auth",
- description: undefined | string,
-] {
- const [token, error] = tokenFromRequest(req);
- if (error) {
- let description: string | undefined = undefined;
- switch (error) {
- case "invalid-header": {
- description = "Invalid Authorization header";
- break;
- }
- case "not-bearer": {
- description = "Authorization header must be a Bearer token";
- break;
- }
- default: {
- break;
- }
- }
- return ["invalid_request", description];
- }
-
- if (!token) {
- return ["no_auth", undefined];
- }
-
- try {
- parseUserJwt(token);
- } catch (jwtParseError) {
- if (jwtParseError instanceof jsonwebtoken.TokenExpiredError) {
- return ["invalid_token", "Token expired"];
- }
- if (jwtParseError instanceof jsonwebtoken.NotBeforeError) {
- return ["invalid_token", "Token not yet valid"];
- }
- if (jwtParseError instanceof jsonwebtoken.JsonWebTokenError) {
- return ["invalid_token", "Invalid token"];
- }
- return ["invalid_token", "Invalid token"];
- }
-
- return [undefined, undefined];
-}
-
-/**
- * @deprecated Use the export from @ukdanceblue/common instead
- */
-export { defaultAuthorization } from "@ukdanceblue/common";
diff --git a/packages/server/src/repositories/Session.ts b/packages/server/src/repositories/Session.ts
index 2f8783d2..e31d6e51 100644
--- a/packages/server/src/repositories/Session.ts
+++ b/packages/server/src/repositories/Session.ts
@@ -1,5 +1,5 @@
import { type Container, Service } from "@freshgum/typedi";
-import { PrismaClient, Session } from "@prisma/client";
+import { type Person, PrismaClient, Session } from "@prisma/client";
import { AuthSource } from "@ukdanceblue/common";
import {
ConcreteError,
@@ -9,6 +9,7 @@ import {
UnauthenticatedError,
} from "@ukdanceblue/common/error";
import { type CookieOptions, Handler } from "express";
+import express from "express";
import jsonwebtoken from "jsonwebtoken";
import { DateTime, Duration } from "luxon";
import { AsyncResult, Err, Ok } from "ts-results-es";
@@ -28,20 +29,22 @@ import {
} from "./person/PersonRepository.js";
import { type AsyncRepositoryResult, type RepositoryError } from "./shared.js";
+export const SESSION_LENGTH = Duration.fromObject({ day: 1 });
+const JWT_ISSUER = "https://danceblue.org";
+export const SESSION_COOKIE_NAME = "ukdanceblue_session";
+
+export type SessionValue = Session & { person: Person | null };
+
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
- session: Session | null;
+ session: SessionValue | null;
getService: typeof Container.get;
}
}
}
-export const SESSION_LENGTH = Duration.fromObject({ day: 1 });
-const JWT_ISSUER = "https://danceblue.org";
-const SESSION_COOKIE_NAME = "ukdanceblue_session";
-
@Service([prismaToken, jwtSecretToken, isDevelopmentToken, PersonRepository])
export class SessionRepository extends buildDefaultRepository("Session", {}) {
constructor(
@@ -72,11 +75,11 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
ip,
userAgent,
}: {
- user: UniquePersonParam;
- userAgent: string;
- ip: string;
+ user: UniquePersonParam | null;
authSource: AuthSource;
- }): AsyncRepositoryResult {
+ ip?: string;
+ userAgent?: string;
+ }): AsyncRepositoryResult {
if (authSource === AuthSource.None) {
return Err(
new InvariantError(
@@ -90,9 +93,12 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
authSource,
ip,
userAgent,
- person: { connect: this.personRepository.uniqueToWhere(user) },
+ person: user
+ ? { connect: this.personRepository.uniqueToWhere(user) }
+ : undefined,
expiresAt: DateTime.now().plus(SESSION_LENGTH).toJSDate(),
},
+ include: { person: true },
})
);
}
@@ -100,7 +106,7 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
verifySession(
token: string,
{ ip, userAgent }: { ip?: string; userAgent?: string }
- ): AsyncRepositoryResult {
+ ): AsyncRepositoryResult {
return new AsyncResult(
new Promise((resolve) => {
verify(
@@ -125,6 +131,7 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
return this.handleQueryError(
this.prisma.session.findUnique({
where: { uuid: decoded },
+ include: { person: true },
})
);
})
@@ -169,19 +176,38 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
);
}
- refreshSession(session: Session): AsyncRepositoryResult {
+ refreshSession(session: Session): AsyncRepositoryResult {
return this.handleQueryError(
this.prisma.session.update({
where: { uuid: session.uuid },
data: {
expiresAt: DateTime.now().plus(SESSION_LENGTH).toJSDate(),
},
+ include: { person: true },
})
);
}
+ deleteSession(session: Session): AsyncRepositoryResult {
+ return this.handleQueryError(
+ this.prisma.session.delete({ where: { uuid: session.uuid } })
+ ).map(() => undefined);
+ }
+
+ gcOldSessions(): AsyncRepositoryResult {
+ return this.handleQueryError(
+ this.prisma.session.deleteMany({
+ where: { expiresAt: { lte: new Date() } },
+ })
+ ).map(() => undefined);
+ }
+
get expressMiddleware(): Handler {
- return async (req, res, next) => {
+ return async (
+ req: express.Request,
+ res: express.Response,
+ next: express.NextFunction
+ ) => {
let token = (req.cookies as Partial>)[
SESSION_COOKIE_NAME
]
@@ -213,10 +239,9 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
userAgent: req.headers["user-agent"],
})
.andThen(
- (session): AsyncRepositoryResult => {
- if (!session) {
- return Err(new UnauthenticatedError()).toAsyncResult();
- }
+ (
+ session
+ ): AsyncRepositoryResult => {
req.session = session;
return this.refreshSession(session);
}
@@ -246,4 +271,25 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) {
}
};
}
+
+ doExpressRedirect(
+ req: express.Request,
+ res: express.Response,
+ jwt: string,
+ redirectTo: string,
+ returning: (string | object | undefined)[]
+ ) {
+ if (returning.includes("token")) {
+ redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`;
+ }
+ if (returning.includes("cookie")) {
+ res.cookie(SESSION_COOKIE_NAME, jwt, {
+ httpOnly: true,
+ sameSite: req.secure ? "none" : "lax",
+ secure: req.secure,
+ expires: DateTime.now().plus(SESSION_LENGTH).toJSDate(),
+ });
+ }
+ return res.redirect(redirectTo);
+ }
}
diff --git a/packages/server/src/resolvers/MarathonHourResolver.ts b/packages/server/src/resolvers/MarathonHourResolver.ts
index 6d056812..97651e69 100644
--- a/packages/server/src/resolvers/MarathonHourResolver.ts
+++ b/packages/server/src/resolvers/MarathonHourResolver.ts
@@ -93,7 +93,9 @@ export class MarathonHourResolver
@Arg("marathonUuid", () => GlobalIdScalar) marathonUuid: GlobalId
) {
const marathonHour = await this.marathonHourRepository.createMarathonHour({
- ...input,
+ durationInfo: input.durationInfo,
+ title: input.title,
+ details: input.details,
marathon: { uuid: marathonUuid.id },
shownStartingAt: input.shownStartingAt.toJSDate(),
});
@@ -109,7 +111,9 @@ export class MarathonHourResolver
const marathonHour = await this.marathonHourRepository.updateMarathonHour(
{ uuid: id },
{
- ...input,
+ details: input.details,
+ durationInfo: input.durationInfo,
+ title: input.title,
shownStartingAt: input.shownStartingAt.toJSDate(),
}
);
diff --git a/packages/server/src/resolvers/MarathonResolver.ts b/packages/server/src/resolvers/MarathonResolver.ts
index 8a650a1c..b792fe6b 100644
--- a/packages/server/src/resolvers/MarathonResolver.ts
+++ b/packages/server/src/resolvers/MarathonResolver.ts
@@ -168,7 +168,7 @@ export class MarathonResolver
async createMarathon(@Arg("input") input: CreateMarathonInput) {
return new AsyncResult(
this.marathonRepository.createMarathon({
- ...input,
+ year: input.year,
startDate: input.startDate?.toJSDate() ?? null,
endDate: input.endDate?.toJSDate() ?? null,
})
@@ -189,7 +189,7 @@ export class MarathonResolver
const marathon = await this.marathonRepository.updateMarathon(
{ uuid: id },
{
- ...input,
+ year: input.year,
startDate: input.startDate?.toJSDate() ?? null,
endDate: input.endDate?.toJSDate() ?? null,
}
diff --git a/packages/server/src/routes/api/auth/anonymous.ts b/packages/server/src/routes/api/auth/anonymous.ts
index fba76fe9..2382c8a5 100644
--- a/packages/server/src/routes/api/auth/anonymous.ts
+++ b/packages/server/src/routes/api/auth/anonymous.ts
@@ -1,50 +1,46 @@
import { AuthSource } from "@ukdanceblue/common";
import type { NextFunction, Request, Response } from "express";
-import { DateTime } from "luxon";
-import { makeUserJwt } from "#auth/index.js";
+import { SessionRepository } from "#repositories/Session.js";
-export const anonymousLogin = (
+export const anonymousLogin = async (
req: Request,
res: Response,
next: NextFunction
-): void => {
+): Promise => {
try {
let redirectTo = "/";
const queryRedirectTo = Array.isArray(req.query.redirectTo)
? req.query.redirectTo[0]
: req.query.redirectTo;
+ const returning = Array.isArray(req.query.returning)
+ ? req.query.returning
+ : [req.query.returning];
if (queryRedirectTo && (queryRedirectTo as string).length > 0) {
redirectTo = queryRedirectTo as string;
} else {
return void res.status(400).send("Missing redirectTo query parameter");
}
- let setCookie = false;
- let sendToken = false;
- const returning = Array.isArray(req.query.returning)
- ? req.query.returning
- : [req.query.returning];
- if (returning.includes("cookie")) {
- setCookie = true;
- }
- if (returning.includes("token")) {
- sendToken = true;
- }
-
- const jwt = makeUserJwt({
- authSource: AuthSource.Anonymous,
- });
- if (setCookie) {
- res.cookie("token", jwt, {
- httpOnly: true,
- sameSite: "lax",
- expires: DateTime.utc().plus({ weeks: 2 }).toJSDate(),
- });
- }
- if (sendToken) {
- redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`;
+ const sessionRepository = req.getService(SessionRepository);
+ const jwt = await sessionRepository
+ .newSession({
+ user: null,
+ authSource: AuthSource.Demo,
+ ip: req.ip,
+ userAgent: req.headers["user-agent"],
+ })
+ .andThen((session) => sessionRepository.signSession(session)).promise;
+ if (jwt.isErr()) {
+ next(jwt.error);
+ } else {
+ return sessionRepository.doExpressRedirect(
+ req,
+ res,
+ jwt.value,
+ redirectTo,
+ returning
+ );
}
- return res.redirect(redirectTo);
} catch (error) {
res.clearCookie("token");
next(error);
diff --git a/packages/server/src/routes/api/auth/demo.ts b/packages/server/src/routes/api/auth/demo.ts
index 13aa36e7..17636af3 100644
--- a/packages/server/src/routes/api/auth/demo.ts
+++ b/packages/server/src/routes/api/auth/demo.ts
@@ -1,9 +1,8 @@
import { AuthSource } from "@ukdanceblue/common";
import type { NextFunction, Request, Response } from "express";
-import { DateTime } from "luxon";
-import { makeUserJwt } from "#auth/index.js";
import { getOrMakeDemoUser } from "#lib/demo.js";
+import { SessionRepository } from "#repositories/Session.js";
export const demoLogin = async (
req: Request,
@@ -21,17 +20,9 @@ export const demoLogin = async (
res.status(400);
return void res.send("Missing redirectTo query parameter");
}
- let setCookie = false;
- let sendToken = false;
const returning = Array.isArray(req.query.returning)
? req.query.returning
: [req.query.returning];
- if (returning.includes("cookie")) {
- setCookie = true;
- }
- if (returning.includes("token")) {
- sendToken = true;
- }
const person = await getOrMakeDemoUser();
if (person.isErr()) {
@@ -41,21 +32,28 @@ export const demoLogin = async (
);
}
- const jwt = makeUserJwt({
- userId: person.value.uuid,
- authSource: AuthSource.Demo,
- });
- if (setCookie) {
- res.cookie("token", jwt, {
- httpOnly: true,
- sameSite: "lax",
- expires: DateTime.utc().plus({ weeks: 2 }).toJSDate(),
- });
- }
- if (sendToken) {
- redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`;
+ const sessionRepository = req.getService(SessionRepository);
+ const jwt = await sessionRepository
+ .newSession({
+ user: {
+ id: person.value.id,
+ },
+ authSource: AuthSource.Demo,
+ ip: req.ip,
+ userAgent: req.headers["user-agent"],
+ })
+ .andThen((session) => sessionRepository.signSession(session)).promise;
+ if (jwt.isErr()) {
+ next(jwt.error);
+ } else {
+ return sessionRepository.doExpressRedirect(
+ req,
+ res,
+ jwt.value,
+ redirectTo,
+ returning
+ );
}
- return res.redirect(redirectTo);
} catch (error) {
res.clearCookie("token");
next(error);
diff --git a/packages/server/src/routes/api/auth/index.ts b/packages/server/src/routes/api/auth/index.ts
index 4bbb3f4c..4f9f755f 100644
--- a/packages/server/src/routes/api/auth/index.ts
+++ b/packages/server/src/routes/api/auth/index.ts
@@ -1,6 +1,10 @@
import { Service } from "@freshgum/typedi";
import { RequestHandler, urlencoded } from "express";
+import {
+ SESSION_COOKIE_NAME,
+ SessionRepository,
+} from "#repositories/Session.js";
import { RouterService } from "#routes/RouteService.js";
import { anonymousLogin } from "./anonymous.js";
@@ -8,13 +12,12 @@ import { demoLogin } from "./demo.js";
import { login } from "./login.js";
import { oidcCallback } from "./oidcCallback.js";
-// TODO: Replace custom OAuth2 + middleware implementation with Passport.js and oauth2orize
-// https://www.passportjs.org
-// https://github.com/jaredhanson/oauth2orize
-
-const logout: RequestHandler = (req, res, next) => {
+const logout: RequestHandler = async (req, res, next) => {
+ if (req.session) {
+ await req.getService(SessionRepository).deleteSession(req.session).promise;
+ }
try {
- res.clearCookie("token");
+ res.clearCookie(SESSION_COOKIE_NAME);
let redirectTo = "/";
const queryRedirectTo = Array.isArray(req.query.redirectTo)
diff --git a/packages/server/src/routes/api/auth/login.ts b/packages/server/src/routes/api/auth/login.ts
index 673bbac7..5670c602 100644
--- a/packages/server/src/routes/api/auth/login.ts
+++ b/packages/server/src/routes/api/auth/login.ts
@@ -1,19 +1,17 @@
-import { Container } from "@freshgum/typedi";
import { AuthSource } from "@ukdanceblue/common";
import { ErrorCode } from "@ukdanceblue/common/error";
import type { NextFunction, Request, Response } from "express";
-import { DateTime } from "luxon";
import {
buildAuthorizationUrl,
calculatePKCECodeChallenge,
} from "openid-client";
import { AsyncResult } from "ts-results-es";
-import { makeUserJwt } from "#auth/index.js";
import { getHostUrl } from "#lib/host.js";
import { LoginFlowRepository } from "#repositories/LoginFlowSession.js";
import { personModelToResource } from "#repositories/person/personModelToResource.js";
import { PersonRepository } from "#repositories/person/PersonRepository.js";
+import { SessionRepository } from "#repositories/Session.js";
import { oidcConfiguration } from "./oidcClient.js";
@@ -35,10 +33,10 @@ export const login = async (
next: NextFunction
): Promise => {
try {
- const loginFlowSessionRepository = Container.get(LoginFlowRepository);
+ const loginFlowSessionRepository = req.getService(LoginFlowRepository);
- const queryRedirectTo = getStringQueryParameter(req, "redirectTo");
- if (!queryRedirectTo) {
+ const redirectTo = getStringQueryParameter(req, "redirectTo");
+ if (!redirectTo) {
return void res.status(400).send("Missing redirectTo query parameter");
}
@@ -54,7 +52,8 @@ export const login = async (
return void res.status(405).send("Method Not Allowed");
}
- const personRepository = Container.get(PersonRepository);
+ const personRepository = req.getService(PersonRepository);
+ const sessionRepository = req.getService(SessionRepository);
const person = await new AsyncResult(
personRepository.passwordLogin(email, password)
).andThen((person) => personModelToResource(person, personRepository))
@@ -66,28 +65,32 @@ export const login = async (
? void res.status(401).send("Invalid email or password")
: void res.sendStatus(500);
} else {
- const jwt = makeUserJwt({
- authSource: AuthSource.Password,
- userId: person.value.id.id,
- });
- let redirectTo = queryRedirectTo;
- if (returning.includes("token")) {
- redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`;
+ const jwt = await sessionRepository
+ .newSession({
+ user: {
+ uuid: person.value.id.id,
+ },
+ authSource: AuthSource.Password,
+ ip: req.ip,
+ userAgent: req.headers["user-agent"],
+ })
+ .andThen((session) => sessionRepository.signSession(session)).promise;
+ if (jwt.isErr()) {
+ next(jwt.error);
+ } else {
+ return sessionRepository.doExpressRedirect(
+ req,
+ res,
+ jwt.value,
+ redirectTo,
+ returning
+ );
}
- if (returning.includes("cookie")) {
- res.cookie("token", jwt, {
- httpOnly: true,
- sameSite: req.secure ? "none" : "lax",
- secure: req.secure,
- expires: DateTime.now().plus({ days: 7 }).toJSDate(),
- });
- }
- return res.redirect(redirectTo);
}
} else {
// OIDC login
const session = await loginFlowSessionRepository.startLoginFlow({
- redirectToAfterLogin: queryRedirectTo,
+ redirectToAfterLogin: redirectTo,
setCookie: returning.includes("cookie"),
sendToken: returning.includes("token"),
});
diff --git a/packages/server/src/routes/api/auth/oidcCallback.ts b/packages/server/src/routes/api/auth/oidcCallback.ts
index 139d2927..845c9445 100644
--- a/packages/server/src/routes/api/auth/oidcCallback.ts
+++ b/packages/server/src/routes/api/auth/oidcCallback.ts
@@ -1,16 +1,18 @@
-import { Container } from "@freshgum/typedi";
-import { AuthSource } from "@ukdanceblue/common";
+import { AuthSource, type PersonNode } from "@ukdanceblue/common";
+import {
+ type ConcreteError,
+ InvalidArgumentError,
+} from "@ukdanceblue/common/error";
import type { NextFunction, Request, Response } from "express";
import jsonwebtoken from "jsonwebtoken";
-import { DateTime } from "luxon";
-import { authorizationCodeGrant } from "openid-client";
+import { authorizationCodeGrant, type JsonValue } from "openid-client";
+import { Err, type Result } from "ts-results-es";
-import { makeUserJwt } from "#auth/index.js";
import { getHostUrl } from "#lib/host.js";
-import { logger } from "#lib/logging/standardLogging.js";
import { LoginFlowRepository } from "#repositories/LoginFlowSession.js";
import { personModelToResource } from "#repositories/person/personModelToResource.js";
import { PersonRepository } from "#repositories/person/PersonRepository.js";
+import { SessionRepository } from "#repositories/Session.js";
import { oidcConfiguration } from "./oidcClient.js";
@@ -21,8 +23,9 @@ export const oidcCallback = async (
): Promise => {
let sessionDeleted = true;
- const personRepository = Container.get(PersonRepository);
- const loginFlowSessionRepository = Container.get(LoginFlowRepository);
+ const personRepository = req.getService(PersonRepository);
+ const loginFlowRepository = req.getService(LoginFlowRepository);
+ const sessionRepository = req.getService(SessionRepository);
let flowSessionId;
try {
@@ -30,22 +33,20 @@ export const oidcCallback = async (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
flowSessionId = req.body.state;
} else {
- throw new Error("Missing state parameter");
+ return void res.status(400).json({ message: "Missing state parameter" });
}
-
if (!flowSessionId) {
- return void res.status(400).send("Missing state parameter");
+ return void res.status(400).json({ message: "Missing state parameter" });
}
sessionDeleted = false;
- const session =
- await loginFlowSessionRepository.findLoginFlowSessionByUnique({
- uuid: flowSessionId,
- });
- if (!session?.codeVerifier) {
+ const loginFlow = await loginFlowRepository.findLoginFlowSessionByUnique({
+ uuid: flowSessionId,
+ });
+ if (!loginFlow?.codeVerifier) {
throw new Error(
- `No ${session == null ? "session" : "codeVerifier"} found`
+ `No ${loginFlow == null ? "session" : "codeVerifier"} found`
);
}
const query = new URLSearchParams(
@@ -60,7 +61,7 @@ export const oidcCallback = async (
let tokenSet;
try {
tokenSet = await authorizationCodeGrant(oidcConfiguration, currentUrl, {
- pkceCodeVerifier: session.codeVerifier,
+ pkceCodeVerifier: loginFlow.codeVerifier,
expectedState: flowSessionId,
idTokenExpected: true,
});
@@ -75,7 +76,7 @@ export const oidcCallback = async (
throw error;
}
// Destroy the session
- await loginFlowSessionRepository.completeLoginFlow({
+ await loginFlowRepository.completeLoginFlow({
uuid: flowSessionId,
});
sessionDeleted = true;
@@ -93,123 +94,134 @@ export const oidcCallback = async (
if (!decodedJwt) {
throw new Error("Error decoding JWT");
}
- const {
- given_name: firstName,
- family_name: lastName,
- upn: userPrincipalName,
- } = decodedJwt;
- let linkblue: null | string = null;
- if (
- typeof userPrincipalName === "string" &&
- userPrincipalName.endsWith("@uky.edu")
- ) {
- linkblue = userPrincipalName.replace(/@uky\.edu$/, "").toLowerCase();
- }
- if (typeof objectId !== "string") {
- return void res.status(500).send("Missing OID");
- }
- const findPersonForLoginResult = await personRepository.findPersonForLogin(
- [[AuthSource.LinkBlue, objectId]],
- { email: String(email), linkblue }
- );
-
- if (findPersonForLoginResult.isErr()) {
- return void res
- .status(500)
- .send(
- findPersonForLoginResult.error.expose
- ? findPersonForLoginResult.error.message
- : "Error finding person"
- );
- }
- const { currentPerson } = findPersonForLoginResult.value;
-
- if (
- !currentPerson.authIdPairs.some(
- ({ source, value }) =>
- source === AuthSource.LinkBlue && value === objectId
- )
- ) {
- currentPerson.authIdPairs = [
- ...currentPerson.authIdPairs,
- {
- personId: currentPerson.id,
- source: AuthSource.LinkBlue,
- value: objectId,
- },
- ];
- }
- if (email && currentPerson.email !== email && typeof email === "string") {
- currentPerson.email = email;
- }
- if (typeof firstName === "string" && typeof lastName === "string") {
- const name = `${firstName} ${lastName}`;
- if (currentPerson.name !== name) {
- currentPerson.name = name;
- }
- }
- if (linkblue && currentPerson.linkblue !== linkblue) {
- currentPerson.linkblue = linkblue.toLowerCase();
- }
- const updatedPerson = await personRepository.updatePerson(
- { id: currentPerson.id },
- {
- name: currentPerson.name,
- email: currentPerson.email,
- linkblue: currentPerson.linkblue?.toLowerCase(),
- authIds: currentPerson.authIdPairs.map((a) => ({
- source: a.source,
- value: a.value,
- })),
- }
+ const personNodeResult = await getPersonFromAzureJwt(
+ decodedJwt,
+ objectId,
+ email,
+ personRepository
);
- if (updatedPerson.isErr()) {
- logger.error("Failed to update database entry", {
- error: updatedPerson.error,
- });
- return void res.status(500).send("Failed to update database entry");
- }
+ if (personNodeResult.isErr()) {
+ next(personNodeResult.error);
+ } else {
+ const jwt = await sessionRepository
+ .newSession({
+ user: {
+ uuid: personNodeResult.value.id.id,
+ },
+ authSource: AuthSource.LinkBlue,
+ ip: req.ip,
+ userAgent: req.headers["user-agent"],
+ })
+ .andThen((session) => sessionRepository.signSession(session)).promise;
- const personNode = await personModelToResource(
- updatedPerson.value,
- personRepository
- ).promise;
- if (personNode.isErr()) {
- return void res
- .status(500)
- .send(
- personNode.error.expose
- ? personNode.error.message
- : "Error creating person node"
+ if (jwt.isErr()) {
+ next(jwt.error);
+ } else {
+ return sessionRepository.doExpressRedirect(
+ req,
+ res,
+ jwt.value,
+ loginFlow.redirectToAfterLogin,
+ [
+ loginFlow.sendToken ? "token" : undefined,
+ loginFlow.setCookie ? "cookie" : undefined,
+ ]
);
+ }
}
- const jwt = makeUserJwt({
- userId: personNode.value.id.id,
- authSource: AuthSource.LinkBlue,
- });
- let redirectTo = session.redirectToAfterLogin;
- if (session.sendToken) {
- redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`;
- }
- if (session.setCookie) {
- res.cookie("token", jwt, {
- httpOnly: true,
- sameSite: req.secure ? "none" : "lax",
- secure: req.secure,
- expires: DateTime.now().plus({ days: 7 }).toJSDate(),
- });
- }
- return res.redirect(redirectTo);
} catch (error) {
res.clearCookie("token");
next(error);
} finally {
if (!sessionDeleted) {
- await loginFlowSessionRepository.completeLoginFlow({
+ await loginFlowRepository.completeLoginFlow({
uuid: flowSessionId,
});
}
}
};
+
+async function getPersonFromAzureJwt(
+ decodedJwt: jsonwebtoken.JwtPayload,
+ objectId: JsonValue | undefined,
+ email: JsonValue | undefined,
+ personRepository: PersonRepository
+): Promise> {
+ const {
+ given_name: firstName,
+ family_name: lastName,
+ upn: userPrincipalName,
+ } = decodedJwt;
+ let linkblue: null | string = null;
+ if (
+ typeof userPrincipalName === "string" &&
+ userPrincipalName.endsWith("@uky.edu")
+ ) {
+ linkblue = userPrincipalName.replace(/@uky\.edu$/, "").toLowerCase();
+ }
+ if (typeof objectId !== "string") {
+ return Err(new InvalidArgumentError("Missing OID in JWT"));
+ }
+ const findPersonForLoginResult = await personRepository.findPersonForLogin(
+ [[AuthSource.LinkBlue, objectId]],
+ { email: String(email), linkblue }
+ );
+
+ if (findPersonForLoginResult.isErr()) {
+ return findPersonForLoginResult;
+ }
+ const { currentPerson } = findPersonForLoginResult.value;
+
+ if (
+ !currentPerson.authIdPairs.some(
+ ({ source, value }) =>
+ source === AuthSource.LinkBlue && value === objectId
+ )
+ ) {
+ currentPerson.authIdPairs = [
+ ...currentPerson.authIdPairs,
+ {
+ personId: currentPerson.id,
+ source: AuthSource.LinkBlue,
+ value: objectId,
+ },
+ ];
+ }
+ if (email && currentPerson.email !== email && typeof email === "string") {
+ currentPerson.email = email;
+ }
+ if (typeof firstName === "string" && typeof lastName === "string") {
+ const name = `${firstName} ${lastName}`;
+ if (currentPerson.name !== name) {
+ currentPerson.name = name;
+ }
+ }
+ if (linkblue && currentPerson.linkblue !== linkblue) {
+ currentPerson.linkblue = linkblue.toLowerCase();
+ }
+
+ const updatedPerson = await personRepository.updatePerson(
+ { id: currentPerson.id },
+ {
+ name: currentPerson.name,
+ email: currentPerson.email,
+ linkblue: currentPerson.linkblue?.toLowerCase(),
+ authIds: currentPerson.authIdPairs.map((a) => ({
+ source: a.source,
+ value: a.value,
+ })),
+ }
+ );
+
+ if (updatedPerson.isErr()) {
+ return updatedPerson;
+ }
+
+ const personNode = await personModelToResource(
+ updatedPerson.value,
+ personRepository
+ ).promise;
+ return personNode;
+}
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index 9d87e554..f6b7827b 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -15,12 +15,7 @@ import { setupExpressErrorHandler } from "@sentry/node";
import { ConcreteError, ErrorCode } from "@ukdanceblue/common/error";
import cookieParser from "cookie-parser";
import cors from "cors";
-import type {
- Application as ExpressApplication,
- NextFunction,
- Request,
- Response,
-} from "express";
+import type { Application as ExpressApplication } from "express";
import express from "express";
import type { GraphQLContext } from "#auth/context.js";
@@ -31,7 +26,6 @@ import {
loggingLevelToken,
} from "#lib/typediTokens.js";
import { logger } from "#logging/logger.js";
-import { SessionRepository } from "#repositories/Session.js";
import { mountPortal } from "./portal.js";
@@ -79,33 +73,6 @@ export async function createServer() {
next();
});
- setupExpressErrorHandler(app, {
- shouldHandleError(error) {
- if (
- error instanceof ConcreteError &&
- [
- ErrorCode.AccessControlError,
- ErrorCode.AuthorizationRuleFailed,
- ErrorCode.NotFound,
- ErrorCode.Unauthenticated,
- ].includes(error.tag)
- ) {
- return false;
- }
- return true;
- },
- });
-
- if (loggingLevel === "trace") {
- app.use((err: unknown, req: Request, _: unknown, next: NextFunction) => {
- logger.error("Koa app error", err, {
- method: req.method,
- url: req.url,
- });
- next(err);
- });
- }
-
const httpServer = http.createServer(app);
const apolloServerPlugins = [
@@ -171,7 +138,11 @@ export async function startServer(
apolloServer: ApolloServer,
app: ExpressApplication
) {
+ const { formatError } = await import("#lib/formatError.js");
+ const { SessionRepository } = await import("#repositories/Session.js");
+
const cookieSecret = Container.get(cookieSecretToken);
+ const isDev = Container.get(isDevelopmentToken);
await apolloServer.start();
@@ -219,20 +190,7 @@ export async function startServer(
Container.get(fileRouter).mount(apiRouter);
Container.get(uploadRouter).mount(apiRouter);
- app.use(
- "/api",
- apiRouter,
- (err: unknown, _: Request, res: Response, next: NextFunction) => {
- if (typeof err === "object" && err !== null && "message" in err) {
- const { message, ...errorData } = err;
- logger.error(String(message), { error: errorData });
- res.status(500).send(String(message));
- } else {
- logger.error("Unknown Error in API", { error: err });
- next(err);
- }
- }
- );
+ app.use("/api", apiRouter);
if (process.env.SSR) {
logger.warning(
@@ -276,4 +234,50 @@ export async function startServer(
});
}
}
+
+ setupExpressErrorHandler(app, {
+ shouldHandleError(error) {
+ if (
+ error instanceof ConcreteError &&
+ [
+ ErrorCode.AccessControlError,
+ ErrorCode.AuthorizationRuleFailed,
+ ErrorCode.NotFound,
+ ErrorCode.Unauthenticated,
+ ].includes(error.tag)
+ ) {
+ return false;
+ }
+ return true;
+ },
+ });
+
+ app.use(
+ (
+ err: unknown,
+ _r: express.Request,
+ res: express.Response,
+ _n: express.NextFunction
+ ) => {
+ const formatted = formatError(
+ err instanceof Error
+ ? err
+ : err instanceof ConcreteError
+ ? err.graphQlError
+ : new Error(String(err)),
+ err,
+ isDev
+ );
+ if (
+ formatted.extensions &&
+ "code" in formatted.extensions &&
+ formatted.extensions.code === ErrorCode.Unauthenticated.description
+ ) {
+ res.status(401).json(formatted);
+ } else {
+ logger.error("Unhandled error in Express", { error: formatted });
+ res.status(500).json(formatted);
+ }
+ }
+ );
}