diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..fe02fed --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,8 @@ +{ + "config": { + "MD013": false, + "MD028": false, + "MD041": false, + }, + "ignores": ["node_modules", "pnpm-lock.yaml", ".next", "plop-templates"], +} diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..a29a697 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,111 @@ +# VideoNote + +A video review app where a **User** owns **Projects**, each containing +timestamped **Notes**, and can publish a project as a **Share** so others +can read or contribute via a public URL. + +## Language + +### Domain + +**User**: +A registered account with credentials and personal **Settings**. +_Avoid_: account, member. + +**Project**: +A video plus the **Notes** taken against it. Owned by exactly one **User**. +_Avoid_: video, board. + +**Note**: +A timestamped comment attached to a **Project**. Has `content`, `time`, +`done`, optional author **User**. +_Avoid_: comment, marker. + +**Share**: +A publishing record that exposes a **Project** at a public URL with optional +password protection and an `canEdit` flag. One **Project** has at most one +**Share**. +_Avoid_: link, public link. + +**Settings**: +Per-user playback and UI preferences (e.g. `playOffset`, `seekJump`, +`sidebarWidth`, `currentProject`). + +### Architecture (Identity / Auth seam) + +**AuthContext**: +The bag passed into every handler wrapped by `withAuthenticatedUser`: +`{ userDoc, email, newToken }`. The `newToken` is already rotated; handlers +just echo it back to the client. + +**OptionalAuthContext**: +The discriminated bag for `withOptionalUser`. Either `{ isGuest: true, +userDoc: null, email: null, newToken: null }` or the same shape as +`AuthContext` with `isGuest: false`. + +**withAuthenticatedUser**: +The wrapper that owns the JWT-extraction → verify → user-lookup contract. +Lives in `utils/auth/withAuthenticatedUser.ts`. Handlers never read +`req.headers["authorization"]` directly. + +**withOptionalUser**: +Same wrapper for routes that allow guests (currently only `pages/api/note.ts`). +A _missing_ token routes to the guest branch; a _present-but-invalid_ token +still 401s — there is no silent fallback. + +### Architecture (Share seam) + +**ShareAccessResult**: +The discriminated outcome returned by `verifySharePassword`: +`open` | `passwordRequired` | `incorrect` | `ok`. Lets the read path map +each case to a response without branching on string messages. + +**verifySharePassword / hashSharePassword**: +The pure pair in `utils/share/sharePassword.ts` that owns the password +contract for shares. Both treat empty/null as "no password protection". + +## Relationships + +- A **User** owns many **Projects**; a **Project** has one **User**. +- A **Project** has many **Notes**; a **Note** belongs to one **Project**. +- A **Project** may have one **Share**; a **Share** belongs to one **Project**. +- A guest (no JWT) can create a **Note** against a shared **Project** when the + **Share** has `canEdit: true`. They never own a **User**. + +## Example dialogue + +> **Dev:** "When a guest hits `/api/note` to add a **Note**, who's recorded +> as the author?" +> **Domain:** "Nobody. The **Note** persists with `user` undefined. The +> wrapper signals guest mode via `ctx.isGuest === true`, and the handler +> skips the `user` assignment." + +> **Dev:** "If a **Share** has no password, what does +> `verifySharePassword` return?" +> **Domain:** "`{ kind: 'open' }`. The same shape whether `storedHash` is +> `null`, `undefined`, or `""` — those all mean unprotected. That's why the +> public read path no longer crashes on the legacy null case." + +## Known follow-ups + +These are deliberately out of scope for the current change but worth +re-suggesting in a future architecture review: + +- **public_project status codes**: `passwordRequired` and `incorrect` + currently respond with HTTP 200 + `msg` because the existing client at + `src/context/globalContext.tsx` keys off `data.msg` rather than + `res.status`. Migrating both server and client to 401/403 would let + generic HTTP middleware handle these cases. +- **Mongoose `Document.remove()` deprecation**: `pages/api/user.js` calls + `userDoc.remove()` (legacy API) and `projectDoc.remove()` for project + cleanup. Both should be replaced with `deleteOne()` to align with the + bundled Mongoose version. +- **`utils/apiHelpers.ts` field names**: the helpers strip `created` and + `updated` from documents but the Mongoose schemas use `createdAt` / + `updatedAt` (timestamps option). The strip currently does nothing. +- **`globalContext.tsx` god-object**: 792 LOC, 28 exposed properties; a + separate review should consider splitting it along the same seam lines + used for the API (Identity, Project, Note, Share). +- **`pages/api/project.ts` & `pages/api/note.ts` action dispatch**: a + single handler with a `switch(action)` for 6+ ops makes individual + branches hard to test without exercising the whole route. diff --git a/README.md b/README.md index e0aad85..dd0c786 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,31 @@ Create a `.env.local` based on the `.env.example` for local development. ### Run it `npm i && npm run dev` + +## API conventions + +Routes under `pages/api/` use two small higher-order wrappers from +`utils/auth/withAuthenticatedUser.ts` to keep authentication out of the +handler bodies: + +- **`withAuthenticatedUser(handler)`** — required for routes that mutate user + data (`auth`, `user`, `settings`, `project`). The handler receives a third + `ctx` argument with `{ userDoc, email, newToken }`. Missing/invalid tokens + short-circuit with 401; missing users short-circuit with 401. +- **`withOptionalUser(handler)`** — used by `pages/api/note.ts`, which allows + guest note creation. The handler receives a discriminated `ctx`: either + `{ isGuest: true, ... null }` or the full authenticated context. A token + that is present but invalid still 401s — there is no silent fallback to the + guest branch. + +Shared-project access goes through `utils/share/sharePassword.ts`: + +- **`hashSharePassword(plaintext)`** — used by `pages/api/project.ts` when + creating or updating a Share document. Returns `null` for empty input. +- **`verifySharePassword(storedHash, candidate)`** — used by + `pages/api/public_project.ts` when reading a shared project. Returns a + `ShareAccessResult` discriminated union: `open` | `passwordRequired` | + `incorrect` | `ok`. + +Both modules have unit tests under `src/__test__/` that exercise behaviour +through their public interface. diff --git a/pages/api/auth.js b/pages/api/auth.js index 2260b0c..3416289 100644 --- a/pages/api/auth.js +++ b/pages/api/auth.js @@ -1,54 +1,26 @@ import { StatusCodes } from "http-status-codes"; import { extractUser } from "@/utils/apiHelpers"; -import { authenticateToken } from "@/utils/jwt"; -import { User } from "@/utils/mongoose"; +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; -export default async (req, res) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - - let email; - try { - email = await authenticateToken(token); - } catch (err) { - console.error(err.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token" }); - } - - // get user via email (including their settings, projects & notes per project) - const user = await User.findOne({ email }) - .populate({ path: "settings", model: "Settings" }) - .populate({ +export default withAuthenticatedUser(async (req, res, { userDoc }) => { + await userDoc.populate([ + { path: "settings", model: "Settings" }, + { path: "projects", model: "Project", populate: [ { path: "notes", model: "Note", - populate: { - path: "user", - model: "User", - select: "username email", - }, - }, - { - path: "share", - model: "Share", + populate: { path: "user", model: "User", select: "username email" }, }, + { path: "share", model: "Share" }, ], - }) - .lean(); - - if (user === null) return res.status(StatusCodes.BAD_REQUEST).json({ msg: "No user found." }); + }, + ]); res.status(StatusCodes.OK).json({ - user: extractUser(user), + user: extractUser(userDoc.toObject()), }); -}; +}); diff --git a/pages/api/note.ts b/pages/api/note.ts index be518f7..8ab75b2 100644 --- a/pages/api/note.ts +++ b/pages/api/note.ts @@ -1,5 +1,5 @@ import { StatusCodes } from "http-status-codes"; -import { NextApiRequest, NextApiResponse } from "next"; +import { NextApiResponse } from "next"; import { NoteApiAction, @@ -7,47 +7,17 @@ import { NoteInterface, UserDocInterface, } from "@/root/src/components/shared/types"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; -import { Note, Project, User } from "@/utils/mongoose"; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - console.log("note api"); - // detect user via token - // if no user then the 'note' is not assigned a user - let isGuestUser: boolean = false; - - // Gather the jwt access token from the request header - let token: string = req.headers["authorization"]; - // strip 'bearer' - if (token) { - token = token.replace(/bearer /i, ""); - } else { - // if there is no token then we could have a guest - console.log("Guest user"); - isGuestUser = true; - } +import { withOptionalUser } from "@/utils/auth/withAuthenticatedUser"; +import { generateAccessToken } from "@/utils/jwt"; +import { Note, Project } from "@/utils/mongoose"; +export default withOptionalUser(async (req, res, ctx) => { // 'note' is inclusive of projectId const { action, note }: { action: NoteApiAction; note: NoteInterface } = req.body; - // if the user is not a guest then grab their information - let userDoc: UserDocInterface; - if (!isGuestUser) { - let email: string; - try { - email = authenticateToken(token); - } catch (error) { - console.error(error.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token", error }); - } - - // get user - userDoc = await User.findOne({ email }); - } - // different route if we choose to delete all complete notes from specified project if (action === NoteApiAction.REMOVE_DONE_NOTES) { - return await removeDoneNotes(res, userDoc, req.body.projectId); + return await removeDoneNotes(res, ctx.userDoc, req.body.projectId); } let noteDoc: NoteDocInterface; @@ -62,7 +32,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { if (noteDoc) { // update user if we have one (in case we have a different user modifying a note) // todo: array of users who modify the note when original 'user' is present? - if (!isGuestUser) data.user = userDoc._id as any; + if (!ctx.isGuest) data.user = ctx.userDoc._id as any; await noteDoc.updateOne({ $set: data }); await noteDoc.save(); @@ -72,7 +42,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // create note // add user info to note - if (!isGuestUser) note.user = userDoc._id as any; + if (!ctx.isGuest) note.user = ctx.userDoc._id as any; // create with whole 'note' since we are passing a manually created _id for faster state management client side noteDoc = new Note(note); @@ -83,29 +53,23 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { await projectDoc.save(); } } - if (action === NoteApiAction.REMOVE) { - } } catch (error) { console.error(error); return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); } - let newToken: string = null; - // token (keep resetting their session length) - if (!isGuestUser) newToken = generateAccessToken(userDoc.email); - // populate the 'user' field if it is a user created note if (noteDoc.user && !noteDoc.populated("user")) await noteDoc.populate("user", "username email"); res.status(StatusCodes.OK).json({ note: noteDoc.toObject(), - token: newToken, + token: ctx.newToken, }); -}; +}); const removeDoneNotes = async ( res: NextApiResponse, - userDoc: UserDocInterface, + userDoc: UserDocInterface | null, projectId: string, ): Promise => { console.log("removing completed notes from project:", projectId); @@ -114,8 +78,8 @@ const removeDoneNotes = async ( // return all notes for project const notes = await Note.find({ project: projectId }).lean(); - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); + // token (keep resetting their session length) — guests get null + const newToken = userDoc ? generateAccessToken(userDoc.email) : null; res.status(StatusCodes.OK).json({ notes, diff --git a/pages/api/project.ts b/pages/api/project.ts index 5eb9776..8e7f633 100644 --- a/pages/api/project.ts +++ b/pages/api/project.ts @@ -10,35 +10,14 @@ * @copyright © 2020 - 2020 MU */ -import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; -import { NextApiRequest, NextApiResponse } from "next"; import { ProjectApiActions, ProjectDocInterface, ShareDocInterface } from "@/shared/types"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; -import { Note, Project, Share, User } from "@/utils/mongoose"; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - - // validate - let email: string; - try { - email = authenticateToken(token); - } catch (err) { - console.error(err.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token" }); - } - - // get user - const userDoc = await User.findOne({ email }); +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; +import { Note, Project, Share } from "@/utils/mongoose"; +import { hashSharePassword } from "@/utils/share/sharePassword"; +export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => { // data passed const { action, project } = req.body; @@ -94,7 +73,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { shareDoc = await Share.findById(projectDoc.share); // hash password if we are given one if (shareData.password?.length > 0) - shareData.password = await bcrypt.hash(shareData.password, 10); + shareData.password = await hashSharePassword(shareData.password); // update share project doc await shareDoc.updateOne({ $set: shareData }); await shareDoc.save(); @@ -110,7 +89,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // hash password if one is provided and overwrite if (shareData.password?.length > 0) - shareData.password = await bcrypt.hash(shareData.password, 10); + shareData.password = await hashSharePassword(shareData.password); try { // create share doc @@ -160,9 +139,6 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { projectDoc.share = null; await projectDoc.save(); - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); - projectDoc = await getEntireProject({ _id: projectDoc._id, }); @@ -172,8 +148,6 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { project: projectDoc.toObject(), }); - break; - case ProjectApiActions.REMOVE: // get projectDoc via project and user _id projectDoc = await Project.findOne({ @@ -198,15 +172,11 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // remove project await projectDoc.deleteOne(); - // token (keep resetting their session length) - const newTokenFromRemoveAction = generateAccessToken(userDoc.email); - return res.status(StatusCodes.OK).json({ // toObject method does not work on removed/deleted mongoose document project: projectDoc, - token: newTokenFromRemoveAction, + token: newToken, }); - break; default: // no action @@ -217,14 +187,11 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); } - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); - return res.status(StatusCodes.OK).json({ project: projectDoc.toObject(), token: newToken, }); -}; +}); const getEntireProject = async (query: { [key: string]: any }): Promise => { return Project.findOne(query).populate([ diff --git a/pages/api/public_project.ts b/pages/api/public_project.ts index 7138f99..5931574 100644 --- a/pages/api/public_project.ts +++ b/pages/api/public_project.ts @@ -10,12 +10,12 @@ * @copyright © 2020 - 2020 MU */ -import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; import { NextApiRequest, NextApiResponse } from "next"; import { ProjectDocInterface, ShareDocInterface } from "@/shared/types"; import { Project, Share } from "@/utils/mongoose"; +import { verifySharePassword } from "@/utils/share/sharePassword"; // GET 1 PROJECT export default async (req: NextApiRequest, res: NextApiResponse) => { @@ -24,24 +24,21 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // get project let projectDoc: ProjectDocInterface; - let shareDoc: ShareDocInterface; try { - shareDoc = await Share.findOne({ url: shareUrl }); - - if (shareDoc.password.length > 0) { - // 'shared project password required' + const shareDoc: ShareDocInterface = await Share.findOne({ url: shareUrl }); + if (!shareDoc) { + return res.status(StatusCodes.BAD_REQUEST).json({ msg: "Share url does not exist." }); + } - if (password) { - // compare passwords - const match = await bcrypt.compare(password, shareDoc.password); - console.log({ match }); - if (!match) { - return res.status(StatusCodes.OK).json({ msg: "password incorrect" }); - } - } else { - // else - return res.status(StatusCodes.OK).json({ msg: "shared project password required" }); - } + const access = await verifySharePassword(shareDoc.password, password); + // Status code stays 200 here so the existing client (globalContext.tsx) + // keeps keying off `data.msg`. See CONTEXT.md follow-ups for the planned + // 401 migration once the client switches to status-based handling. + if (access.kind === "passwordRequired") { + return res.status(StatusCodes.OK).json({ msg: "shared project password required" }); + } + if (access.kind === "incorrect") { + return res.status(StatusCodes.OK).json({ msg: "password incorrect" }); } projectDoc = await Project.findById(shareDoc.project) diff --git a/pages/api/settings.js b/pages/api/settings.js index d2bee34..906f4ce 100644 --- a/pages/api/settings.js +++ b/pages/api/settings.js @@ -1,28 +1,9 @@ import { StatusCodes } from "http-status-codes"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; -import { Settings, User } from "@/utils/mongoose"; - -export default async (req, res) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - - let email; - try { - email = await authenticateToken(token); - } catch (error) { - console.error(error.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token", error }); - } - - // get user - const userDoc = await User.findOne({ email }); +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; +import { Settings } from "@/utils/mongoose"; +export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => { const { settings } = req.body; let settingsDoc; @@ -53,11 +34,8 @@ export default async (req, res) => { return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); } - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); - res.status(StatusCodes.OK).json({ settings: settingsDoc.toObject(), token: newToken, }); -}; +}); diff --git a/pages/api/user.js b/pages/api/user.js index 76c9d8f..e898be1 100644 --- a/pages/api/user.js +++ b/pages/api/user.js @@ -2,31 +2,12 @@ import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; import { extractUser } from "@/utils/apiHelpers"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; import { Note, Project, Settings, Share, User } from "@/utils/mongoose"; -export default async (req, res) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - +export default withAuthenticatedUser(async (req, res, { userDoc, email, newToken }) => { const { action, user: userData } = req.body; - let email; - try { - email = await authenticateToken(token); - } catch (err) { - console.error(err.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token" }); - } - - // get user - const userDoc = await User.findOne({ email }); - try { if (action === "update") { await updateUser(userDoc, userData); @@ -56,8 +37,8 @@ export default async (req, res) => { // remove user settings doc await Settings.deleteOne({ user: userDoc._id }); - // remove user - await userDoc.remove(); + // remove user (legacy Mongoose API; see CONTEXT.md follow-ups) + await /** @type {any} */ (userDoc).remove(); return res.status(StatusCodes.OK).json({ msg: `${userDoc.email} removed` }); } @@ -71,14 +52,11 @@ export default async (req, res) => { // get updated user const updatedUser = await User.findOne({ email }).lean(); - // token (keep resetting their session length) - const newToken = generateAccessToken(updatedUser.email); - res.status(StatusCodes.OK).json({ user: extractUser(updatedUser), token: newToken, }); -}; +}); const updateUser = async (userDoc, userData) => { // if we have settings data to update and we have previously stored data then merge diff --git a/src/__test__/auth/withAuthenticatedUser.test.ts b/src/__test__/auth/withAuthenticatedUser.test.ts new file mode 100644 index 0000000..ef3d137 --- /dev/null +++ b/src/__test__/auth/withAuthenticatedUser.test.ts @@ -0,0 +1,128 @@ +import { StatusCodes } from "http-status-codes"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/utils/jwt", () => ({ + authenticateToken: vi.fn(), + generateAccessToken: vi.fn(), +})); + +vi.mock("@/utils/mongoose", () => ({ + User: { findOne: vi.fn() }, +})); + +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; +import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { User } from "@/utils/mongoose"; + +type MockResponse = { + status: ReturnType; + json: ReturnType; +}; + +const buildReq = (headers: Record = {}): NextApiRequest => + ({ headers, body: {} }) as unknown as NextApiRequest; + +const buildRes = (): MockResponse & NextApiResponse => { + const res: MockResponse = { + status: vi.fn(), + json: vi.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as MockResponse & NextApiResponse; +}; + +describe("withAuthenticatedUser", () => { + beforeEach(() => { + vi.mocked(generateAccessToken).mockReturnValue("rotated-token"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when authorization header is absent", async () => { + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + const req = buildReq(); + const res = buildRes(); + await wrapped(req, res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "No token. Authorization denied." }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("strips the Bearer prefix case-insensitively before verifying the token", async () => { + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue({ _id: "u1", email: "user@example.com" } as never); + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + await wrapped(buildReq({ authorization: "bearer abc.def.ghi" }), buildRes()); + + expect(authenticateToken).toHaveBeenCalledWith("abc.def.ghi"); + }); + + it("returns 401 when jwt verification throws", async () => { + vi.mocked(authenticateToken).mockImplementation(() => { + throw new Error("jwt expired"); + }); + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + const res = buildRes(); + await wrapped(buildReq({ authorization: "Bearer bad.token" }), res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "Invalid token" }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("returns 401 when no user matches the verified email", async () => { + vi.mocked(authenticateToken).mockReturnValue("ghost@example.com"); + vi.mocked(User.findOne).mockResolvedValue(null as never); + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + const res = buildRes(); + await wrapped(buildReq({ authorization: "Bearer t" }), res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "No user found." }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("invokes the handler with userDoc, email and a freshly rotated token on the happy path", async () => { + const userDoc = { _id: "u1", email: "user@example.com" }; + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue(userDoc as never); + vi.mocked(generateAccessToken).mockReturnValue("fresh-jwt"); + const handler = vi.fn().mockResolvedValue(undefined); + const wrapped = withAuthenticatedUser(handler); + + const req = buildReq({ authorization: "Bearer good.token" }); + const res = buildRes(); + await wrapped(req, res); + + expect(generateAccessToken).toHaveBeenCalledWith("user@example.com"); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(req, res, { + userDoc, + email: "user@example.com", + newToken: "fresh-jwt", + }); + }); + + it("propagates handler errors so the framework's error handling can run", async () => { + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue({ _id: "u1", email: "user@example.com" } as never); + const boom = new Error("db down"); + const handler = vi.fn().mockRejectedValue(boom); + const wrapped = withAuthenticatedUser(handler); + + await expect(wrapped(buildReq({ authorization: "Bearer t" }), buildRes())).rejects.toBe(boom); + }); +}); diff --git a/src/__test__/auth/withOptionalUser.test.ts b/src/__test__/auth/withOptionalUser.test.ts new file mode 100644 index 0000000..bb351a0 --- /dev/null +++ b/src/__test__/auth/withOptionalUser.test.ts @@ -0,0 +1,93 @@ +import { StatusCodes } from "http-status-codes"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/utils/jwt", () => ({ + authenticateToken: vi.fn(), + generateAccessToken: vi.fn(), +})); + +vi.mock("@/utils/mongoose", () => ({ + User: { findOne: vi.fn() }, +})); + +import { withOptionalUser } from "@/utils/auth/withAuthenticatedUser"; +import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { User } from "@/utils/mongoose"; + +type MockResponse = { + status: ReturnType; + json: ReturnType; +}; + +const buildReq = (headers: Record = {}): NextApiRequest => + ({ headers, body: {} }) as unknown as NextApiRequest; + +const buildRes = (): MockResponse & NextApiResponse => { + const res: MockResponse = { status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as MockResponse & NextApiResponse; +}; + +describe("withOptionalUser", () => { + beforeEach(() => { + vi.mocked(generateAccessToken).mockReturnValue("rotated-token"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("invokes the handler with a guest context when no authorization header is present", async () => { + const handler = vi.fn(); + const wrapped = withOptionalUser(handler); + + const req = buildReq(); + const res = buildRes(); + await wrapped(req, res); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(req, res, { + isGuest: true, + userDoc: null, + email: null, + newToken: null, + }); + expect(res.status).not.toHaveBeenCalled(); + expect(authenticateToken).not.toHaveBeenCalled(); + }); + + it("invokes the handler with an authenticated context when a valid token is supplied", async () => { + const userDoc = { _id: "u1", email: "user@example.com" }; + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue(userDoc as never); + vi.mocked(generateAccessToken).mockReturnValue("fresh-jwt"); + const handler = vi.fn(); + const wrapped = withOptionalUser(handler); + + await wrapped(buildReq({ authorization: "Bearer good.token" }), buildRes()); + + expect(handler).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + isGuest: false, + userDoc, + email: "user@example.com", + newToken: "fresh-jwt", + }); + }); + + it("returns 401 when a token is supplied but invalid (does not silently fall through to guest)", async () => { + vi.mocked(authenticateToken).mockImplementation(() => { + throw new Error("jwt malformed"); + }); + const handler = vi.fn(); + const wrapped = withOptionalUser(handler); + + const res = buildRes(); + await wrapped(buildReq({ authorization: "Bearer bad" }), res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "Invalid token" }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__test__/share/sharePassword.test.ts b/src/__test__/share/sharePassword.test.ts new file mode 100644 index 0000000..dd4a8dc --- /dev/null +++ b/src/__test__/share/sharePassword.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { hashSharePassword, verifySharePassword } from "@/utils/share/sharePassword"; + +describe("verifySharePassword", () => { + it("treats a null stored hash as open access", async () => { + const result = await verifySharePassword(null, "anything"); + expect(result).toEqual({ kind: "open" }); + }); + + it("treats an empty stored hash as open access", async () => { + const result = await verifySharePassword("", "anything"); + expect(result).toEqual({ kind: "open" }); + }); + + it("treats an undefined stored hash as open access", async () => { + const result = await verifySharePassword(undefined, undefined); + expect(result).toEqual({ kind: "open" }); + }); + + it("requires a password when one is stored but none is supplied", async () => { + const stored = await hashSharePassword("secret"); + const result = await verifySharePassword(stored, undefined); + expect(result).toEqual({ kind: "passwordRequired" }); + }); + + it("rejects an incorrect password against the stored hash", async () => { + const stored = await hashSharePassword("secret"); + const result = await verifySharePassword(stored, "wrong"); + expect(result).toEqual({ kind: "incorrect" }); + }); + + it("accepts the correct password against the stored hash", async () => { + const stored = await hashSharePassword("secret"); + const result = await verifySharePassword(stored, "secret"); + expect(result).toEqual({ kind: "ok" }); + }); +}); + +describe("hashSharePassword", () => { + it("returns null for an empty plaintext (no password protection)", async () => { + expect(await hashSharePassword("")).toBeNull(); + }); + + it("returns null for a null plaintext", async () => { + expect(await hashSharePassword(null)).toBeNull(); + }); + + it("returns a bcrypt hash that round-trips through verifySharePassword", async () => { + const hash = await hashSharePassword("hunter2"); + expect(hash).not.toBeNull(); + expect(hash).not.toBe("hunter2"); + expect(await verifySharePassword(hash, "hunter2")).toEqual({ kind: "ok" }); + }); +}); diff --git a/src/components/shared/types.ts b/src/components/shared/types.ts index c98fbce..da9c41d 100644 --- a/src/components/shared/types.ts +++ b/src/components/shared/types.ts @@ -93,6 +93,11 @@ export interface ShareDocInterface extends Document { export interface UserDocInterface extends Document { email: string; + username?: string; + password?: string; + role?: string; + projects: any; + settings?: any; } export interface NoteDocInterface extends Document { user: string | UserInterface; diff --git a/utils/auth/withAuthenticatedUser.ts b/utils/auth/withAuthenticatedUser.ts new file mode 100644 index 0000000..079eb48 --- /dev/null +++ b/utils/auth/withAuthenticatedUser.ts @@ -0,0 +1,125 @@ +import { StatusCodes } from "http-status-codes"; +import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +import type { UserDocInterface } from "@/shared/types"; +import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { User } from "@/utils/mongoose"; + +/** + * Context passed to handlers wrapped by {@link withAuthenticatedUser}. + * + * - `userDoc`: the Mongoose user document for the verified caller + * - `email`: the email extracted from the JWT (matches `userDoc.email`) + * - `newToken`: a freshly rotated JWT the handler should return so the + * caller's session is refreshed + */ +export type AuthContext = { + userDoc: UserDocInterface; + email: string; + newToken: string; +}; + +/** + * Context passed to handlers wrapped by {@link withOptionalUser}. + * + * When the request carries no `authorization` header `isGuest` is `true` and + * `userDoc`/`email`/`newToken` are `null`. When a token is present it must be + * valid; an invalid token short-circuits with 401 (no silent fall-through to + * the guest branch). + */ +export type OptionalAuthContext = + | { isGuest: true; userDoc: null; email: null; newToken: null } + | { isGuest: false; userDoc: UserDocInterface; email: string; newToken: string }; + +export type AuthenticatedHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: AuthContext, +) => unknown | Promise; + +export type OptionalAuthHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: OptionalAuthContext, +) => unknown | Promise; + +// Anchored at the start so a bogus header like "garbage bearer x.y.z" does not +// silently slice into a valid-looking token; it falls through and 401s. +const extractBearer = (header: string | string[] | undefined): string | null => { + if (typeof header !== "string" || header.length === 0) return null; + return header.replace(/^bearer\s+/i, ""); +}; + +type ResolveResult = { + ctx: AuthContext | null; + status: number; + body: { msg: string } | null; +}; + +const resolveAuthenticatedUser = async (token: string): Promise => { + let email: string; + try { + email = authenticateToken(token); + } catch { + return { ctx: null, status: StatusCodes.UNAUTHORIZED, body: { msg: "Invalid token" } }; + } + + const userDoc = (await User.findOne({ email })) as UserDocInterface | null; + if (!userDoc) { + return { ctx: null, status: StatusCodes.UNAUTHORIZED, body: { msg: "No user found." } }; + } + + return { + ctx: { userDoc, email, newToken: generateAccessToken(email) }, + status: StatusCodes.OK, + body: null, + }; +}; + +/** + * Wrap a Next.js API handler so it only runs for authenticated users. + * + * The wrapper performs token extraction, JWT verification, and user lookup, + * then invokes `handler(req, res, ctx)` with a populated {@link AuthContext}. + * On any failure it responds with 401 and a `msg` body and the inner handler + * is not called. Handler errors propagate so the framework's error handling + * can run. + */ +export const withAuthenticatedUser = + (handler: AuthenticatedHandler): NextApiHandler => + async (req, res) => { + const token = extractBearer(req.headers["authorization"]); + if (!token) { + res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); + return; + } + const result = await resolveAuthenticatedUser(token); + if (result.ctx === null) { + res.status(result.status).json(result.body); + return; + } + await handler(req, res, result.ctx); + }; + +/** + * Wrap a Next.js API handler so it accepts both guests and authenticated + * users. + * + * Missing `authorization` header → guest branch (`ctx.isGuest === true`). + * Present-but-invalid token → 401 (no silent guest fall-through). + */ +export const withOptionalUser = + (handler: OptionalAuthHandler): NextApiHandler => + async (req, res) => { + const token = extractBearer(req.headers["authorization"]); + if (!token) { + await handler(req, res, { isGuest: true, userDoc: null, email: null, newToken: null }); + return; + } + const result = await resolveAuthenticatedUser(token); + if (result.ctx === null) { + res.status(result.status).json(result.body); + return; + } + await handler(req, res, { isGuest: false, ...result.ctx }); + }; diff --git a/utils/jwt.ts b/utils/jwt.ts index 0fe3134..c9c5df8 100644 --- a/utils/jwt.ts +++ b/utils/jwt.ts @@ -3,16 +3,17 @@ * generate token secret > require('crypto').randomBytes(64).toString('hex') */ -const jwt = require("jsonwebtoken"); +import jwt from "jsonwebtoken"; + +type TokenPayload = { email: string }; export const authenticateToken = (token: string): string => { - const decoded = jwt.verify(token, process.env.JWT_TOKEN_SECRET); - // @ts-ignore + const decoded = jwt.verify(token, process.env.JWT_TOKEN_SECRET) as TokenPayload; return decoded.email; }; -export const generateAccessToken = (email: string) => { +export const generateAccessToken = (email: string): string => { // expires after half and hour (1800 seconds = 30 minutes) - const data = { email }; + const data: TokenPayload = { email }; return jwt.sign(data, process.env.JWT_TOKEN_SECRET, { expiresIn: 60 * 30 }); }; diff --git a/utils/share/sharePassword.ts b/utils/share/sharePassword.ts new file mode 100644 index 0000000..085f218 --- /dev/null +++ b/utils/share/sharePassword.ts @@ -0,0 +1,49 @@ +import bcrypt from "bcryptjs"; + +/** + * Outcome of attempting to access a share with a candidate password. + * + * - `open`: the share has no password set; access is unrestricted + * - `passwordRequired`: a password is set but the caller did not supply one + * - `incorrect`: a password was supplied but does not match + * - `ok`: the supplied password matches the stored hash + */ +export type ShareAccessResult = + | { kind: "open" } + | { kind: "passwordRequired" } + | { kind: "incorrect" } + | { kind: "ok" }; + +const BCRYPT_SALT_ROUNDS = 10; + +/** + * Decide whether the caller is allowed to read a share, given the share's + * stored password hash and the candidate password they supplied. + * + * Null/empty stored hash means the share is unprotected. A protected share + * with no candidate returns `passwordRequired` — distinguishing "you forgot + * to send a password" from "your password is wrong". + */ +export const verifySharePassword = async ( + storedHash: string | null | undefined, + candidate: string | null | undefined, +): Promise => { + if (!storedHash || storedHash.length === 0) return { kind: "open" }; + if (!candidate) return { kind: "passwordRequired" }; + const match = await bcrypt.compare(candidate, storedHash); + return match ? { kind: "ok" } : { kind: "incorrect" }; +}; + +/** + * Hash a plaintext password for persistence on a Share document. + * + * Returns `null` for empty/null input — callers should treat that as "no + * password protection". This keeps the protection contract symmetrical with + * {@link verifySharePassword}. + */ +export const hashSharePassword = async ( + plaintext: string | null | undefined, +): Promise => { + if (!plaintext || plaintext.length === 0) return null; + return bcrypt.hash(plaintext, BCRYPT_SALT_ROUNDS); +};