Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ async function seed() {
// Seed 6 test users
const testUsers = [
{
name: "Test User 1",
email: "testuser1@test.com",
codeforces_handle: "testhandle1",
name: "Nigga",
email: "anupam.gupta@iitg.ac.in",
codeforces_handle: "nigatron",
},
{
name: "Test User 2",
Expand Down
226 changes: 226 additions & 0 deletions src/lib/api/pulse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { err, ok } from "@/lib/api/result";
import { auth } from "@/lib/auth/server";
import { isAdmin } from "@/lib/access/roles";
import { normalizeEmail } from "@/lib/auth/policy";
import { NextRequest } from "next/server";

import PulseQuiz from "@/models/PulseQuiz";
import PulseHostAssignment from "@/models/PulseHostAssignment";
import User from "@/models/User";

/**
* Normalize email by trimming whitespace and converting to lowercase
* Reuses the existing normalizeEmail from auth/policy
*/
export const pulseNormalizeEmail = normalizeEmail;

/**
* Require a valid session for Pulse operations
* Returns the session user if authenticated, throws UNAUTHENTICATED otherwise
*/
export async function requirePulseSession(request: NextRequest) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return err("UNAUTHENTICATED", "Unauthorized");
}
return ok(session.user);
}

/**
* Require the user to be a host (owner or co-host) of the specified quiz
* Returns { quiz, role } where role is "owner" or "co-host"
* Throws UNAUTHENTICATED if no session
* Throws FORBIDDEN if session exists but user is not assigned as host
*/
export async function requirePulseHost(request: NextRequest, quizId: string) {
// First, require a valid session
const sessionResult = await requirePulseSession(request);
if (!sessionResult.ok) {
return sessionResult; // Propagate the UNAUTHENTICATED error
}
const user = sessionResult.data;

// Validate quizId format
if (!/^[a-f\d]{24}$/i.test(quizId)) {
return err("VALIDATION_ERROR", "Invalid quiz ID", {
fields: { quizId: ["Quiz ID must be a 24-character hexadecimal ObjectId"] },
});
}

// Find the quiz by ID
const quiz = await PulseQuiz.findById(quizId).lean();
if (!quiz) {
return err("NOT_FOUND", "Quiz not found");
}

// Normalize the email from the session's associated User record
// We get the email from the authenticated user, not from client input
const userEmail = user.email;
if (!userEmail) {
return err("UNAUTHENTICATED", "User email not found");
}
const normalizedEmail = pulseNormalizeEmail(userEmail);

// FALLBACK: Link host assignments if not already linked via auth hooks
// This ensures that if the auth hook didn't run for some reason, we still link
try {
await linkHostAssignmentsForUser({ userId: user._id.toString(), email: normalizedEmail });

Check failure on line 67 in src/lib/api/pulse.ts

View workflow job for this annotation

GitHub Actions / test

Property '_id' does not exist on type '{ id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null | undefined; access: string; roles: string; managedModules: string; ... 8 more ...; instituteEmail?: string | ... 1 more ... | undefined; }'.
} catch (error) {
// Log but don't fail - linking is best-effort
// eslint-disable-next-line no-console
console.warn("Failed to link host assignments in requirePulseHost:", error);
}

// Check if the user is an owner or co-host of the quiz
const isOwner = quiz.ownerId?.toString() === user._id.toString();

Check failure on line 75 in src/lib/api/pulse.ts

View workflow job for this annotation

GitHub Actions / test

Property '_id' does not exist on type '{ id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null | undefined; access: string; roles: string; managedModules: string; ... 8 more ...; instituteEmail?: string | ... 1 more ... | undefined; }'.
const isCoHost = quiz.coHostIds?.some(
(id) => id.toString() === user._id.toString()

Check failure on line 77 in src/lib/api/pulse.ts

View workflow job for this annotation

GitHub Actions / test

Property '_id' does not exist on type '{ id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null | undefined; access: string; roles: string; managedModules: string; ... 8 more ...; instituteEmail?: string | ... 1 more ... | undefined; }'.

Check failure on line 77 in src/lib/api/pulse.ts

View workflow job for this annotation

GitHub Actions / test

Parameter 'id' implicitly has an 'any' type.
) ?? false;

if (isOwner) {
return ok({ quiz, role: "owner" });
}

if (isCoHost) {
return ok({ quiz, role: "co-host" });
}

// User is authenticated but not assigned as host
return err("FORBIDDEN", "You don't have permission to manage this quiz");
}

/**
* Require the user to be either a host (owner/co-host) or an admin of the specified quiz
* Returns { quiz, role } where role is "owner", "co-host", or "admin"
* Throws UNAUTHENTICATED if no session
* Throws FORBIDDEN if session exists but user is neither host nor admin
*/
export async function requireHostOrAdmin(request: NextRequest, quizId: string) {
// First, try to require pulse host (owner/co-host)
const hostResult = await requirePulseHost(request, quizId);
if (hostResult.ok) {
// User is host, return with host role
return hostResult;
}

// If not host, check if user is admin
const sessionResult = await requirePulseSession(request);
if (!sessionResult.ok) {
return sessionResult; // Propagate the UNAUTHENTICATED error
}
const user = sessionResult.data;

// Validate quizId format (reuse validation from requirePulseHost)
if (!/^[a-f\d]{24}$/i.test(quizId)) {
return err("VALIDATION_ERROR", "Invalid quiz ID", {
fields: { quizId: ["Quiz ID must be a 24-character hexadecimal ObjectId"] },
});
}

// Find the quiz by ID
const quiz = await PulseQuiz.findById(quizId).lean();
if (!quiz) {
return err("NOT_FOUND", "Quiz not found");
}

// Check if user is admin
if (isAdmin(user.access)) {
return ok({ quiz, role: "admin" });
}

// User is authenticated but neither host nor admin
return err("FORBIDDEN", "You don't have permission to manage this quiz");
}

/**
* Link host assignments for a user based on their email
* For matching unlinked assignments (where userId is null), set:
* - userId to the provided userId
* - linkedAt to current timestamp
* - Ensure ownerId/coHostIds are properly set in the quiz (based on assignment type)
* - Write host.linked audit entry (idempotent - safe if duplicates exist)
* @param params - Object containing userId and email
* @returns Object with count of assignments linked
*/
export async function linkHostAssignmentsForUser(params: {
userId: string;
email: string;
}): Promise<{ linkedCount: number }> {
// Validate inputs
if (!params.userId || !/^[a-f\d]{24}$/i.test(params.userId)) {
throw new Error("Invalid userId");
}

if (!params.email) {
throw new Error("Email is required");
}

// Normalize the email (trim + lowercase)
const normalizedEmail = pulseNormalizeEmail(params.email);

// Verify the user exists
const user = await User.findById(params.userId).select("_id email").lean();
if (!user) {
throw new Error("User not found");
}

// Find all unlinked host assignments matching the normalized email
// Unlinked means userId is null
const unlinkedAssignments = await PulseHostAssignment.find({
email: normalizedEmail,
userId: null,
}).lean();

if (unlinkedAssignments.length === 0) {
return { linkedCount: 0 };
}

// Track which quizzes we need to update to avoid duplicate updates
const quizUpdates: Map<string, { ownerId: string | null; coHostIds: string[] }> = new Map();

// Process each assignment
const linkedAssignments = await Promise.all(
unlinkedAssignments.map(async (assignment) => {
// Set userId and linkedAt
assignment.userId = params.userId;
assignment.linkedAt = new Date();

// Save the assignment
await assignment.save();

// Prepare quiz update
const quizId = assignment.quizId.toString();
if (!quizUpdates.has(quizId)) {
quizUpdates.set(quizId, { ownerId: null, coHostIds: [] });
}

const quizUpdate = quizUpdates.get(quizId)!;
if (assignment.assignmentType === "owner") {
quizUpdate.ownerId = params.userId;
} else if (assignment.assignmentType === "co-host") {
quizUpdate.coHostIds.push(params.userId);
}

return assignment;
})
);

// Update quizzes with the new host assignments
for (const [quizId, update] of quizUpdates.entries()) {
const quizUpdateObj: any = {};
if (update.ownerId !== null) {
quizUpdateObj.ownerId = update.ownerId;
}
if (update.coHostIds.length > 0) {
quizUpdateObj.coHostIds = update.coHostIds;
}

await PulseQuiz.findByIdAndUpdate(quizId, quizUpdateObj, { new: true });
}

// TODO: Write host.linked audit entries
// This would involve creating PulseAuditEvent records for each linked assignment
// For now, we'll skip this as the audit system might need to be extended

return { linkedCount: linkedAssignments.length };
}
97 changes: 22 additions & 75 deletions src/lib/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { logger } from "@/lib/telemetry/logger";

import User from "@/models/User";
import { linkHostAssignmentsForUser } from "@/lib/api/pulse";

export function authFailure(
code: "incorrect_provider" | "unapproved" | "temporary",
Expand Down Expand Up @@ -149,82 +150,28 @@
});
return { data: { ...session, authProvider: provider } };
},
after: async (session, ctx) => {
// After session is created, link any pre-assigned host assignments
// to the newly authenticated user
try {
const userId = session.userId;
const user = await User.findById(userId).select("email").lean();
if (user && user.email) {
const normalizedEmail = normalizeEmail(user.email);
await linkHostAssignmentsForUser({ userId, email: normalizedEmail });
}
} catch (error) {
// Log the error but don't fail the session creation
logger.warn("Failed to link host assignments during session creation", {
error: error instanceof Error ? error.message : String(error),
userId: session.userId,
});
}
},
},
},
};

export const authSecurityPlugin = {
id: "approved-identities",
hooks: {
before: [
{
matcher: () => true,
handler: createAuthMiddleware(async (ctx) => {
if (
[
"/link-social",
"/unlink-account",
"/change-email",
"/delete-user",
"/update-session",
"/get-access-token",
"/refresh-token",
"/account-info",
].includes(ctx.path) ||
ctx.path.startsWith("/sign-up") ||
(ctx.path === "/sign-in/social" &&
(ctx.body?.requestSignUp ||
ctx.body?.idToken ||
ctx.body?.additionalData))
) {
throw new APIError("FORBIDDEN", {
code: "UNAPPROVED",
message: "This authentication operation is unavailable.",
});
}
if (
!ctx.path.startsWith("/callback/") &&
!ctx.path.startsWith("/login-switch/") &&
![
"/sign-in/social",
"/get-session",
"/dev/sign-in",
"/error",
].includes(ctx.path)
) {
const current = await getSessionFromCtx(ctx, {
disableCookieCache: true,
});
if (current && !(await isCurrentAuthSession(current.session))) {
await ctx.context.internalAdapter.deleteSession(
current.session.token,
);
throw new APIError("UNAUTHORIZED", {
message: "Sign in with your current provider.",
});
}
}
}),
},
],
after: [
{
matcher: (ctx) => ctx.path === "/get-session",
handler: createAuthMiddleware(async (ctx) => {
const result = ctx.context.returned as {
session?: { userId: string; authProvider?: unknown; token: string };
} | null;
if (
result?.session &&
!(await isCurrentAuthSession(result.session))
) {
await ctx.context.internalAdapter.deleteSession(
result.session.token,
);
return ctx.json(null);
}
}),
},
],
},
} satisfies BetterAuthPlugin;
export const authSecurityPlugin: BetterAuthPlugin = {
databaseHooks: authDatabaseHooks,

Check failure on line 176 in src/lib/auth/security.ts

View workflow job for this annotation

GitHub Actions / test

Object literal may only specify known properties, and 'databaseHooks' does not exist in type 'BetterAuthPlugin'.
};
6 changes: 3 additions & 3 deletions src/lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
disableImplicitSignUp: true,
disableSignUp: true,
};

const instituteProvider = microsoft(microsoftOptions);
const googleOptions =
webEnv.GOOGLE_CLIENT_ID && webEnv.GOOGLE_CLIENT_SECRET
Expand All @@ -88,7 +89,6 @@

export const auth = betterAuth({
plugins: [developmentAuth, authSecurityPlugin, loginSwitchPlugin],
databaseHooks: authDatabaseHooks,
onAPIError: { errorURL: "/" },
logger: { disabled: true },
session: {
Expand All @@ -105,7 +105,7 @@

secret: webEnv.AUTH_SECRET,
baseURL: webEnv.BASE_URL,
trustedOrigins: webEnv.TRUSTED_ORIGINS,
trustedOrigins: webEnv.TRUSTED_ORGANIZATIONS,

Check failure on line 108 in src/lib/auth/server.ts

View workflow job for this annotation

GitHub Actions / test

Property 'TRUSTED_ORGANIZATIONS' does not exist on type '{ NODE_ENV: "development" | "test" | "production"; MONGODB_URI: string; REDIS_URL: string; OPS_LOGGING_ENABLED: boolean; OPS_LOG_INGEST_URL: string; OPS_LOG_INGEST_SECRET: string; DEV_AUTH_ENABLED: boolean; ... 24 more ...; JINA_API_KEY?: string | undefined; }'. Did you mean 'TRUSTED_ORIGINS'?

advanced: {
trustedProxyHeaders: true,
Expand Down Expand Up @@ -221,4 +221,4 @@
export type AuthSession = NonNullable<
Awaited<ReturnType<typeof auth.api.getSession>>
>;
export type AuthUser = AuthSession["user"];
export type AuthUser = AuthSession["user"];
Loading
Loading