From 9eca6f78a5ad6cacc9feabad3463b0fad6fc7122 Mon Sep 17 00:00:00 2001 From: oliverhuangcode Date: Fri, 3 Jul 2026 00:09:44 +1000 Subject: [PATCH] feat: migrate auth to central MAC service, key data by String macUserId Replace NextAuth/Mongo-adapter auth with the central passwordless auth service (auth.monashcoding.com). The Next.js server layer now acts as the JWT-verifying resource server: - add lib/mac-auth.ts: requireMacUserId() mints a JWT via /api/auth/token and verifies it locally against the central JWKS (EdDSA, iss/aud) -> macUserId - add lib/mac-session.tsx: MacSessionProvider/useMacSession backed by /api/auth/get-session, plus social sign-in / sign-out helpers - my-applications actions: retype userId ObjectId -> String and drop all new ObjectId(userId) conversions; auth via requireMacUserId() - sign-in page: Google + Microsoft social; remove password form - delete [...nextauth] route, lib/auth.ts, and the /sign-up password flow - swap useSession/signOut across nav + client components - deps: remove next-auth, @auth/mongodb-adapter, bcryptjs; add jose - env: add NEXT_PUBLIC_AUTH_URL/AUTH_URL/JWT_AUDIENCE; drop NEXTAUTH_*/GOOGLE_* Co-Authored-By: Claude Opus 4.8 --- frontend/.env.example | 14 +- frontend/package-lock.json | 231 +----------------- frontend/package.json | 4 +- .../src/app/api/auth/[...nextauth]/route.ts | 8 - frontend/src/app/my-applications/actions.ts | 141 ++++------- frontend/src/app/sign-in/page.tsx | 102 ++++---- frontend/src/app/sign-up/actions.ts | 42 ---- frontend/src/app/sign-up/page.tsx | 114 --------- .../applications/my-applications-client.tsx | 4 +- .../src/components/auth/session-provider.tsx | 4 +- frontend/src/components/jobs/job-details.tsx | 4 +- .../src/components/layout/nav-bar-mobile.tsx | 6 +- frontend/src/components/layout/nav-links.tsx | 6 +- .../applications-statistics-client.tsx | 4 +- frontend/src/lib/auth.ts | 82 ------- frontend/src/lib/mac-auth.ts | 70 ++++++ frontend/src/lib/mac-session.tsx | 130 ++++++++++ 17 files changed, 324 insertions(+), 642 deletions(-) delete mode 100644 frontend/src/app/api/auth/[...nextauth]/route.ts delete mode 100644 frontend/src/app/sign-up/actions.ts delete mode 100644 frontend/src/app/sign-up/page.tsx delete mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/mac-auth.ts create mode 100644 frontend/src/lib/mac-session.tsx diff --git a/frontend/.env.example b/frontend/.env.example index d685be7..222d0d3 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,15 +1,13 @@ MONGODB_URI= MONGODB_DATABASE=default -# Auth.js (NextAuth) -NEXTAUTH_SECRET= -NEXTAUTH_URL=http://localhost:3000 - -# OAuth providers -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +# Central MAC auth service (https://auth.monashcoding.com) +# Client-side base URL (sign-in/out, get-session) +NEXT_PUBLIC_AUTH_URL=https://auth.monashcoding.com +# Server-side base URL (token mint + JWKS verification) +AUTH_URL=https://auth.monashcoding.com +JWT_AUDIENCE=mac-suite # PostHog NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ac072a..ccb9dd4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@auth/mongodb-adapter": "^3.11.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -20,15 +19,14 @@ "@tailwindcss/typography": "^0.5.16", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", - "bcryptjs": "^3.0.3", "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", + "jose": "^6.2.3", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", "mongodb": "^6.14.2", "motion": "^12.38.0", "next": "^16.2.3", - "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "posthog-js": "^1.374.2", @@ -83,74 +81,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/@auth/mongodb-adapter": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@auth/mongodb-adapter/-/mongodb-adapter-3.11.1.tgz", - "integrity": "sha512-xY+VUkC3CNXct8UwQgBAQqXASqolSlIARg6oAm1378CtRN2650tQUCOEnGLNLmroVefUeP73M6t+TpGXq72vwQ==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.41.1" - }, - "peerDependencies": { - "mongodb": "^6" - } - }, - "node_modules/@auth/mongodb-adapter/node_modules/@auth/core": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", - "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", - "license": "ISC", - "dependencies": { - "@panva/hkdf": "^1.2.1", - "jose": "^6.0.6", - "oauth4webapi": "^3.3.0", - "preact": "10.24.3", - "preact-render-to-string": "6.5.11" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7.0.7" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/@auth/mongodb-adapter/node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/mongodb-adapter/node_modules/oauth4webapi": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", - "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/mongodb-adapter/node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", - "license": "MIT", - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1929,15 +1859,6 @@ "node": ">=14" } }, - "node_modules/@panva/hkdf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", - "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2887,15 +2808,6 @@ "node": ">=6.0.0" } }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3190,15 +3102,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/core-js": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", @@ -5414,9 +5317,9 @@ } }, "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5958,38 +5861,6 @@ } } }, - "node_modules/next-auth": { - "version": "4.24.13", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", - "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", - "license": "ISC", - "dependencies": { - "@babel/runtime": "^7.20.13", - "@panva/hkdf": "^1.0.2", - "cookie": "^0.7.0", - "jose": "^4.15.5", - "oauth": "^0.9.15", - "openid-client": "^5.4.0", - "preact": "^10.6.3", - "preact-render-to-string": "^5.1.19", - "uuid": "^8.3.2" - }, - "peerDependencies": { - "@auth/core": "0.34.3", - "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", - "nodemailer": "^7.0.7", - "react": "^17.0.2 || ^18 || ^19", - "react-dom": "^17.0.2 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "@auth/core": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6040,12 +5911,6 @@ "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", "license": "MIT" }, - "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6176,15 +6041,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/oidc-token-hash": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", - "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", - "license": "MIT", - "engines": { - "node": "^10.13.0 || >=12.0.0" - } - }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -6203,48 +6059,6 @@ "wrappy": "1" } }, - "node_modules/openid-client": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", - "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", - "license": "MIT", - "dependencies": { - "jose": "^4.15.9", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/openid-client/node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/openid-client/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6738,28 +6552,6 @@ "url": "https://opencollective.com/preact" } }, - "node_modules/preact": { - "version": "10.24.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", - "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/preact-render-to-string": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", - "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", - "license": "MIT", - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6786,12 +6578,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "license": "MIT" - }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -8452,15 +8238,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6349fbe..a179bb8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,6 @@ "format": "npx prettier . --write" }, "dependencies": { - "@auth/mongodb-adapter": "^3.11.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -22,15 +21,14 @@ "@tailwindcss/typography": "^0.5.16", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", - "bcryptjs": "^3.0.3", "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", + "jose": "^6.2.3", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", "mongodb": "^6.14.2", "motion": "^12.38.0", "next": "^16.2.3", - "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "posthog-js": "^1.374.2", diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index f05c0e9..0000000 --- a/frontend/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import NextAuth from "next-auth"; -import { getAuthOptions } from "@/lib/auth"; - -function handler(...args: Parameters>) { - return NextAuth(getAuthOptions())(...args); -} - -export { handler as GET, handler as POST }; diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index 9a0a8a6..8a79b5d 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -1,9 +1,8 @@ "use server"; import { getMongoClientPromise } from "@/lib/mongodb"; -import { getAuthOptions } from "@/lib/auth"; +import { requireMacUserId } from "@/lib/mac-auth"; import logger from "@/lib/logger"; -import { getServerSession } from "next-auth"; import type { Db } from "mongodb"; import { ObjectId } from "mongodb"; import { @@ -18,7 +17,7 @@ import { } from "@/types/application"; type RecruitmentCycleRecord = { - userId: ObjectId; + userId: string; cycleId: string; name: string; isDefault: boolean; @@ -28,7 +27,7 @@ type RecruitmentCycleRecord = { type ApplicationRecord = { _id: ObjectId; - userId: ObjectId; + userId: string; jobId: string; status: ApplicationStatus; startedAt: Date; @@ -41,7 +40,7 @@ type ApplicationRecord = { type ApplicationStatusEventRecord = { _id: ObjectId; - userId: ObjectId; + userId: string; jobId: string; fromStatus?: ApplicationStatus | null; toStatus: ApplicationStatus; @@ -52,13 +51,6 @@ type ApplicationStatusEventRecord = { let statusEventIndexesPromise: Promise | null = null; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function requireUserId(session: any) { - const id = (session?.user as { id?: string } | undefined)?.id; - if (!id) throw new Error("Not authenticated"); - return id; -} - function serializeCycle( doc: Partial, ): RecruitmentCycle { @@ -107,14 +99,14 @@ function serializeStatusEvent( }; } -async function ensureDefaultCycle(db: Db, userObjectId: ObjectId) { +async function ensureDefaultCycle(db: Db, userId: string) { const now = new Date(); await db.collection("application_cycles").updateOne( - { userId: userObjectId, cycleId: DEFAULT_RECRUITMENT_CYCLE_ID }, + { userId, cycleId: DEFAULT_RECRUITMENT_CYCLE_ID }, { $setOnInsert: { - userId: userObjectId, + userId, cycleId: DEFAULT_RECRUITMENT_CYCLE_ID, name: "Current cycle", isDefault: true, @@ -150,7 +142,7 @@ async function ensureStatusEventIndexes(db: Db) { async function recordApplicationStatusEvent( db: Db, - userObjectId: ObjectId, + userId: string, event: { jobId: string; fromStatus?: ApplicationStatus | null; @@ -167,7 +159,7 @@ async function recordApplicationStatusEvent( .collection("application_status_events") .insertOne({ _id: new ObjectId(), - userId: userObjectId, + userId, jobId: event.jobId, fromStatus: event.fromStatus ?? null, toStatus: event.toStatus, @@ -195,18 +187,16 @@ function parseLocalDate(value: string | undefined, fallback: Date) { } export async function listRecruitmentCycles(): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); - await ensureDefaultCycle(db, userObjectId); + await ensureDefaultCycle(db, userId); const docs = await db .collection("application_cycles") - .find({ userId: userObjectId }) + .find({ userId }) .sort({ isDefault: -1, createdAt: 1 }) .toArray(); @@ -216,9 +206,7 @@ export async function listRecruitmentCycles(): Promise { export async function createRecruitmentCycle( name: string, ): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const trimmed = name.trim(); if (!trimmed) throw new Error("Cycle name is required"); @@ -227,7 +215,7 @@ export async function createRecruitmentCycle( const now = new Date(); const cycleId = new ObjectId().toString(); const doc = { - userId: userObjectId, + userId, cycleId, name: trimmed, isDefault: false, @@ -235,7 +223,7 @@ export async function createRecruitmentCycle( updatedAt: now, }; - await ensureDefaultCycle(db, userObjectId); + await ensureDefaultCycle(db, userId); await db .collection("application_cycles") .insertOne(doc); @@ -247,9 +235,7 @@ export async function renameRecruitmentCycle( cycleId: string, name: string, ): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const trimmed = name.trim(); if (!trimmed) throw new Error("Cycle name is required"); @@ -257,26 +243,24 @@ export async function renameRecruitmentCycle( const db = client.db(process.env.MONGODB_DATABASE || "default"); const now = new Date(); - await ensureDefaultCycle(db, userObjectId); + await ensureDefaultCycle(db, userId); await db .collection("application_cycles") .updateOne( - { userId: userObjectId, cycleId }, + { userId, cycleId }, { $set: { name: trimmed, updatedAt: now } }, ); const doc = await db .collection("application_cycles") - .findOne({ userId: userObjectId, cycleId }); + .findOne({ userId, cycleId }); if (!doc) throw new Error("Cycle not found"); return serializeCycle(doc); } export async function deleteRecruitmentCycle(cycleId: string) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); if (cycleId === DEFAULT_RECRUITMENT_CYCLE_ID) { throw new Error("The default cycle cannot be deleted"); } @@ -284,12 +268,12 @@ export async function deleteRecruitmentCycle(cycleId: string) { const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); - await ensureDefaultCycle(db, userObjectId); + await ensureDefaultCycle(db, userId); await db .collection("application_cycles") - .deleteOne({ userId: userObjectId, cycleId }); + .deleteOne({ userId, cycleId }); const moved = await db.collection("applications").updateMany( - { userId: userObjectId, cycleId }, + { userId, cycleId }, { $set: { cycleId: DEFAULT_RECRUITMENT_CYCLE_ID, updatedAt: new Date() }, }, @@ -299,9 +283,7 @@ export async function deleteRecruitmentCycle(cycleId: string) { } export async function syncLocalApplications(apps: LocalApplication[]) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); @@ -309,7 +291,7 @@ export async function syncLocalApplications(apps: LocalApplication[]) { if (!apps.length) { const current = await collection - .find({ userId: userObjectId }) + .find({ userId }) .sort({ updatedAt: -1 }) .toArray(); @@ -333,14 +315,14 @@ export async function syncLocalApplications(apps: LocalApplication[]) { const localUpdatedAt = parseLocalDate(app.updatedAt, localStartedAt); const nextCycleId = app.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID; const existing = await collection.findOne({ - userId: userObjectId, + userId, jobId: app.jobId, }); if (!existing) { await collection.insertOne({ _id: new ObjectId(), - userId: userObjectId, + userId, jobId: app.jobId, status: app.status, startedAt: localStartedAt, @@ -350,7 +332,7 @@ export async function syncLocalApplications(apps: LocalApplication[]) { ...(app.starred !== undefined ? { starred: app.starred } : {}), }); - await recordApplicationStatusEvent(db, userObjectId, { + await recordApplicationStatusEvent(db, userId, { jobId: app.jobId, fromStatus: null, toStatus: app.status, @@ -380,7 +362,7 @@ export async function syncLocalApplications(apps: LocalApplication[]) { }, ); - await recordApplicationStatusEvent(db, userObjectId, { + await recordApplicationStatusEvent(db, userId, { jobId: app.jobId, fromStatus: existing.status, toStatus: app.status, @@ -392,7 +374,7 @@ export async function syncLocalApplications(apps: LocalApplication[]) { } const current = await collection - .find({ userId: userObjectId }) + .find({ userId }) .sort({ updatedAt: -1 }) .toArray(); @@ -407,15 +389,14 @@ export async function syncLocalApplications(apps: LocalApplication[]) { } export async function listApplications(): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const docs = await db .collection("applications") - .find({ userId: new ObjectId(userId) }) + .find({ userId }) .sort({ updatedAt: -1 }) .limit(500) .toArray(); @@ -452,8 +433,7 @@ export async function listApplications(): Promise { export async function listApplicationStatusEvents( jobIds: string[], ): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); + const userId = await requireMacUserId(); if (!jobIds.length) return []; @@ -464,7 +444,7 @@ export async function listApplicationStatusEvents( const docs = await db .collection("application_status_events") .find({ - userId: new ObjectId(userId), + userId, jobId: { $in: Array.from(new Set(jobIds)) }, }) .sort({ createdAt: 1 }) @@ -478,16 +458,14 @@ export async function addApplication( jobId: string, jobSnapshot: ApplicationJobSnapshot, ) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const now = new Date(); const result = await db.collection("applications").updateOne( - { userId: userObjectId, jobId }, + { userId, jobId }, { $set: { updatedAt: now, jobSnapshot }, $setOnInsert: { @@ -500,7 +478,7 @@ export async function addApplication( ); if (result.upsertedCount > 0) { - await recordApplicationStatusEvent(db, userObjectId, { + await recordApplicationStatusEvent(db, userId, { jobId, fromStatus: null, toStatus: "STARTED", @@ -513,14 +491,13 @@ export async function addApplication( } export async function deleteApplication(jobId: string) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); await db.collection("applications").deleteOne({ - userId: new ObjectId(userId), + userId, jobId, }); @@ -530,9 +507,7 @@ export async function deleteApplication(jobId: string) { export async function restoreDeletedApplication( application: DbApplication, ): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); @@ -544,7 +519,7 @@ export async function restoreDeletedApplication( application.notes.trim().length > 0; await collection.updateOne( - { userId: userObjectId, jobId: application.jobId }, + { userId, jobId: application.jobId }, { $set: { jobId: application.jobId, @@ -558,14 +533,14 @@ export async function restoreDeletedApplication( }, ...(hasNotes ? {} : { $unset: { notes: "" } }), $setOnInsert: { - userId: userObjectId, + userId, }, }, { upsert: true }, ); const restored = await collection.findOne({ - userId: userObjectId, + userId, jobId: application.jobId, }); @@ -580,9 +555,7 @@ export async function createCustomApplication( date: string, cycleId = DEFAULT_RECRUITMENT_CYCLE_ID, ): Promise { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); @@ -592,7 +565,7 @@ export async function createCustomApplication( const parsedDate = new Date(date); const result = await db.collection("applications").insertOne({ - userId: userObjectId, + userId, jobId, status, cycleId, @@ -601,7 +574,7 @@ export async function createCustomApplication( jobSnapshot, }); - await recordApplicationStatusEvent(db, userObjectId, { + await recordApplicationStatusEvent(db, userId, { jobId, fromStatus: null, toStatus: status, @@ -624,17 +597,15 @@ export async function updateApplicationStatus( jobId: string, status: ApplicationStatus, ) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); - const userObjectId = new ObjectId(userId); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const collection = db.collection("applications"); - const existing = await collection.findOne({ userId: userObjectId, jobId }); + const existing = await collection.findOne({ userId, jobId }); await collection.updateOne( - { userId: userObjectId, jobId }, + { userId, jobId }, { $set: { status, updatedAt: new Date() }, $setOnInsert: { @@ -645,7 +616,7 @@ export async function updateApplicationStatus( { upsert: true }, ); - await recordApplicationStatusEvent(db, userObjectId, { + await recordApplicationStatusEvent(db, userId, { jobId, fromStatus: existing?.status ?? null, toStatus: status, @@ -657,22 +628,20 @@ export async function updateApplicationStatus( } export async function toggleApplicationStar(jobId: string, starred: boolean) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); await db .collection("applications") - .updateOne({ userId: new ObjectId(userId), jobId }, { $set: { starred } }); + .updateOne({ userId, jobId }, { $set: { starred } }); return { ok: true, starred }; } export async function updateApplicationNotes(jobId: string, notes: string) { - const session = await getServerSession(getAuthOptions()); - const userId = requireUserId(session); + const userId = await requireMacUserId(); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); @@ -682,9 +651,7 @@ export async function updateApplicationNotes(jobId: string, notes: string) { ? { $set: { notes, updatedAt: new Date() } } : { $unset: { notes: "" }, $set: { updatedAt: new Date() } }; - await db - .collection("applications") - .updateOne({ userId: new ObjectId(userId), jobId }, update); + await db.collection("applications").updateOne({ userId, jobId }, update); return { ok: true, notes: hasNotes ? notes : undefined }; } diff --git a/frontend/src/app/sign-in/page.tsx b/frontend/src/app/sign-in/page.tsx index 4dd6537..93c8dd7 100644 --- a/frontend/src/app/sign-in/page.tsx +++ b/frontend/src/app/sign-in/page.tsx @@ -1,42 +1,31 @@ "use client"; -import { Alert, Button, Card, PasswordInput, TextInput } from "@mantine/core"; +import { Alert, Button, Card } from "@mantine/core"; import Link from "next/link"; -import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; +import { startSocialSignIn } from "@/lib/mac-session"; export default function SignInPage() { const searchParams = useSearchParams(); - const callbackUrl = searchParams.get("callbackUrl") || "/"; + const callbackPath = searchParams.get("callbackUrl") || "/"; - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [loading, setLoading] = useState<"google" | "microsoft" | null>(null); - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const signIn = async (provider: "google" | "microsoft") => { setError(null); - setIsLoading(true); - - const res = await signIn("credentials", { - redirect: false, - email, - password, - callbackUrl, - }); - - if (res?.error) { - setError("Invalid email or password"); - setIsLoading(false); - return; - } - - if (res?.url) { - window.location.href = res.url; - } else { - window.location.href = callbackUrl; + setLoading(provider); + try { + // Absolute URL back to mploy so central can redirect here after auth. + const callbackURL = new URL( + callbackPath, + window.location.origin, + ).toString(); + await startSocialSignIn(provider, callbackURL); + } catch { + setError("Could not start sign-in. Please try again."); + setLoading(null); } }; @@ -45,10 +34,7 @@ export default function SignInPage() {

Sign in

- Don’t have an account?{" "} - - Sign up - + Continue with your Monash Coding account.

{error && ( @@ -57,33 +43,12 @@ export default function SignInPage() { )} -
- setEmail(e.currentTarget.value)} - required - /> - setPassword(e.currentTarget.value)} - required - /> - -

- We handle account and usage data as described in our{" "} - - Privacy Policy - - . -

+
- + +

+ We handle account and usage data as described in our{" "} + + Privacy Policy + + . +

+
); diff --git a/frontend/src/app/sign-up/actions.ts b/frontend/src/app/sign-up/actions.ts deleted file mode 100644 index aa1345a..0000000 --- a/frontend/src/app/sign-up/actions.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use server"; - -import { getMongoClientPromise } from "@/lib/mongodb"; -import { hash } from "bcryptjs"; - -export async function registerUser(input: { - email: string; - password: string; - name?: string; -}) { - const email = input.email.toLowerCase().trim(); - const password = input.password; - const name = input.name?.trim(); - - if (!email || !password) { - throw new Error("Email and password are required"); - } - - if (password.length < 8) { - throw new Error("Password must be at least 8 characters"); - } - - const client = await getMongoClientPromise(); - const db = client.db(process.env.MONGODB_DATABASE || "default"); - - const existing = await db.collection("users").findOne({ email }); - if (existing) { - throw new Error("An account with that email already exists"); - } - - const passwordHash = await hash(password, 12); - - await db.collection("users").insertOne({ - email, - passwordHash, - name: name || null, - createdAt: new Date(), - updatedAt: new Date(), - }); - - return { ok: true }; -} diff --git a/frontend/src/app/sign-up/page.tsx b/frontend/src/app/sign-up/page.tsx deleted file mode 100644 index e89f8a4..0000000 --- a/frontend/src/app/sign-up/page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { Alert, Button, Card, PasswordInput, TextInput } from "@mantine/core"; -import Link from "next/link"; -import { useState } from "react"; -import { registerUser } from "./actions"; -import { signIn } from "next-auth/react"; - -export default function SignUpPage() { - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setIsLoading(true); - try { - await registerUser({ name, email, password }); - await signIn("credentials", { - email, - password, - callbackUrl: "/my-applications", - }); - } catch (err) { - setError(err instanceof Error ? err.message : "Sign up failed"); - setIsLoading(false); - } - }; - - return ( -
- -

Create account

-

- Already have an account?{" "} - - Sign in - -

- - {error && ( - - {error} - - )} - -
- setName(e.currentTarget.value)} - placeholder="Optional" - /> - setEmail(e.currentTarget.value)} - required - /> - setPassword(e.currentTarget.value)} - required - description="At least 8 characters" - /> - -

- By creating an account, you acknowledge that we process your - information as described in our{" "} - - Privacy Policy - - . -

- - -
-
- ); -} diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index 8ca35af..82582cf 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { useSession } from "next-auth/react"; +import { useMacSession } from "@/lib/mac-session"; import { Box, Button, @@ -167,7 +167,7 @@ export default function MyApplicationsClient({ initialStages: UserStage[]; initialCycles: RecruitmentCycle[]; }) { - const { status: sessionStatus } = useSession(); + const { status: sessionStatus } = useMacSession(); const [apps, setApps] = useState(initial); const [stages, setStages] = useState(() => readStageOrder(initialStages), diff --git a/frontend/src/components/auth/session-provider.tsx b/frontend/src/components/auth/session-provider.tsx index 2d3fdac..bbc450f 100644 --- a/frontend/src/components/auth/session-provider.tsx +++ b/frontend/src/components/auth/session-provider.tsx @@ -1,8 +1,8 @@ "use client"; -import { SessionProvider } from "next-auth/react"; +import { MacSessionProvider } from "@/lib/mac-session"; import { PropsWithChildren } from "react"; export default function AuthSessionProvider({ children }: PropsWithChildren) { - return {children}; + return {children}; } diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index d432820..4bc4a31 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -20,7 +20,7 @@ import { addApplication } from "@/app/my-applications/actions"; import { trackApplyClick } from "@/actions/analytics"; import { sendGAEvent } from "@next/third-parties/google"; import Link from "next/link"; -import { useSession } from "next-auth/react"; +import { useMacSession } from "@/lib/mac-session"; const APPLY_SIGNIN_PROMPT_SESSION_KEY = "mp:apply-signin-prompt-shown:v1"; @@ -31,7 +31,7 @@ export default function JobDetails() { const timeoutRef = useRef(null); const signinPromptShownRef = useRef(false); const [showSigninModal, setShowSigninModal] = useState(false); - const { data: session, status: sessionStatus } = useSession(); + const { data: session, status: sessionStatus } = useMacSession(); // Scroll to top whenever a new job is selected useEffect(() => { diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index 1beeed0..7e288f1 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -6,12 +6,12 @@ import Logo from "@/components/layout/logo"; import SearchBar from "@/components/search/search-bar"; import { useState } from "react"; import { usePathname } from "next/navigation"; -import { useSession, signOut } from "next-auth/react"; +import { useMacSession, signOutCentral } from "@/lib/mac-session"; export const NavBarMobile = () => { const [showSearch, setShowSearch] = useState(false); const pathname = usePathname(); - const { data: session, status } = useSession(); + const { data: session, status } = useMacSession(); const menuItems = [ { href: "/", label: "Home" }, @@ -75,7 +75,7 @@ export const NavBarMobile = () => { } - onClick={() => signOut({ callbackUrl: "/" })} + onClick={() => signOutCentral("/")} > Sign out diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index da6e645..0c995a8 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -1,13 +1,13 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useSession, signOut } from "next-auth/react"; +import { useMacSession, signOutCentral } from "@/lib/mac-session"; import { Avatar, Menu } from "@mantine/core"; import { IconUser, IconLogout } from "@tabler/icons-react"; export default function NavLinks() { const pathname = usePathname(); - const { data: session, status } = useSession(); + const { data: session, status } = useMacSession(); const linkClass = (href: string) => `text-lg ${pathname === href ? "font-bold underline-fancy" : ""}`; @@ -56,7 +56,7 @@ export default function NavLinks() { } - onClick={() => signOut({ callbackUrl: "/" })} + onClick={() => signOutCentral("/")} > Sign out diff --git a/frontend/src/components/statistics/applications-statistics-client.tsx b/frontend/src/components/statistics/applications-statistics-client.tsx index b07b18a..2cf38ae 100644 --- a/frontend/src/components/statistics/applications-statistics-client.tsx +++ b/frontend/src/components/statistics/applications-statistics-client.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent, Ref } from "react"; -import { useSession } from "next-auth/react"; +import { useMacSession } from "@/lib/mac-session"; import { Select } from "@mantine/core"; import { IconDownload, IconExternalLink, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; @@ -1008,7 +1008,7 @@ export default function ApplicationsStatisticsClient({ initialEvents: ApplicationStatusEvent[]; initialCycles: RecruitmentCycle[]; }) { - const { status: sessionStatus } = useSession(); + const { status: sessionStatus } = useMacSession(); const shouldReduceMotion = useReducedMotion(); const [selectedCycleId, setSelectedCycleId] = useState( initialCycles[0]?.id ?? DEFAULT_RECRUITMENT_CYCLE_ID, diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts deleted file mode 100644 index c546d98..0000000 --- a/frontend/src/lib/auth.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { NextAuthOptions } from "next-auth"; -import GoogleProvider from "next-auth/providers/google"; -import CredentialsProvider from "next-auth/providers/credentials"; -import { MongoDBAdapter } from "@auth/mongodb-adapter"; -import { getMongoClientPromise } from "@/lib/mongodb"; -import { compare } from "bcryptjs"; -import { ObjectId } from "mongodb"; - -type DbUser = { - _id: ObjectId; - email?: string; - passwordHash?: string; - name?: string | null; -}; - -let _authOptions: NextAuthOptions | undefined; - -export function getAuthOptions(): NextAuthOptions { - if (_authOptions) return _authOptions; - - _authOptions = { - adapter: MongoDBAdapter(getMongoClientPromise(), { - databaseName: process.env.MONGODB_DATABASE || "default", - }), - session: { - strategy: "jwt", - }, - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID || "", - clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", - }), - CredentialsProvider({ - name: "Email and password", - credentials: { - email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" }, - }, - async authorize(credentials) { - const email = (credentials?.email || "").toLowerCase().trim(); - const password = credentials?.password || ""; - - if (!email || !password) return null; - - const client = await getMongoClientPromise(); - const db = client.db(process.env.MONGODB_DATABASE || "default"); - const user = (await db - .collection("users") - .findOne({ email })) as DbUser | null; - if (!user?.passwordHash) return null; - - const ok = await compare(password, user.passwordHash); - if (!ok) return null; - - return { - id: user._id.toString(), - email: user.email, - name: user.name || undefined, - }; - }, - }), - ], - callbacks: { - async jwt({ token, user }) { - if (user?.id) token.id = user.id; - return token; - }, - async session({ session, token }) { - if (session.user && token?.id) { - // @ts-expect-error - add id onto session user - session.user.id = token.id; - } - return session; - }, - }, - pages: { - signIn: "/sign-in", - }, - }; - - return _authOptions; -} diff --git a/frontend/src/lib/mac-auth.ts b/frontend/src/lib/mac-auth.ts new file mode 100644 index 0000000..fd4141c --- /dev/null +++ b/frontend/src/lib/mac-auth.ts @@ -0,0 +1,70 @@ +import { cookies } from "next/headers"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import logger from "@/lib/logger"; + +const AUTH_URL = process.env.AUTH_URL || "https://auth.monashcoding.com"; +const ISSUER = "https://auth.monashcoding.com"; +const AUDIENCE = process.env.JWT_AUDIENCE || "mac-suite"; + +// Cached remote JWKS (EdDSA/Ed25519 keys). jose caches the fetched keys and +// only refreshes when it encounters an unknown `kid`, so this is safe to keep +// at module scope. +const jwks = createRemoteJWKSet(new URL(`${AUTH_URL}/api/auth/jwks`)); + +type MacClaims = { + macUserId?: string; + email?: string; + roles?: string[]; +}; + +/** + * Resolve the current user's opaque `macUserId` (a String) from the shared + * `.monashcoding.com` session cookie, or `null` if unauthenticated. + * + * Flow: forward the session cookie to the central auth service to mint a + * short-lived EdDSA JWT, then verify it locally against the central JWKS + * (validating alg/iss/aud). This makes the Next.js server layer the JWT-verifying + * resource server. The returned id is opaque and must never be parsed as an + * ObjectId. + */ +export async function getMacUserId(): Promise { + const cookieHeader = (await cookies()).toString(); + if (!cookieHeader) return null; + + let token: string | undefined; + try { + const res = await fetch(`${AUTH_URL}/api/auth/token`, { + headers: { cookie: cookieHeader }, + cache: "no-store", + }); + if (!res.ok) return null; + token = (await res.json())?.token; + } catch (error) { + logger.warn({ error }, "Failed to fetch central auth token"); + return null; + } + + if (!token) return null; + + try { + const { payload } = await jwtVerify(token, jwks, { + algorithms: ["EdDSA"], + issuer: ISSUER, + audience: AUDIENCE, + }); + return payload.macUserId ?? null; + } catch (error) { + logger.warn({ error }, "Failed to verify central auth JWT"); + return null; + } +} + +/** + * Like {@link getMacUserId} but throws when there is no authenticated user. + * Mirrors the old `requireUserId(session)` contract. + */ +export async function requireMacUserId(): Promise { + const id = await getMacUserId(); + if (!id) throw new Error("Not authenticated"); + return id; +} diff --git a/frontend/src/lib/mac-session.tsx b/frontend/src/lib/mac-session.tsx new file mode 100644 index 0000000..6d88105 --- /dev/null +++ b/frontend/src/lib/mac-session.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useState, + PropsWithChildren, +} from "react"; + +const AUTH_URL = + process.env.NEXT_PUBLIC_AUTH_URL || "https://auth.monashcoding.com"; + +export type MacSessionUser = { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; +}; + +export type MacSession = { + user: MacSessionUser; +}; + +export type MacSessionStatus = "loading" | "authenticated" | "unauthenticated"; + +// Discriminated union so `status === "authenticated"` narrows `data` to +// non-null at call sites (mirrors next-auth's `useSession`). +type MacSessionContextValue = + | { data: MacSession; status: "authenticated" } + | { data: null; status: "loading" | "unauthenticated" }; + +const MacSessionContext = createContext({ + data: null, + status: "loading", +}); + +/** + * Client-side session provider backed by the central auth service. Replaces + * next-auth's `SessionProvider`. On mount it calls `GET /api/auth/get-session` + * with the shared `.monashcoding.com` cookie and exposes a next-auth-like + * `{ data, status }` shape. + */ +export function MacSessionProvider({ children }: PropsWithChildren) { + const [value, setValue] = useState({ + data: null, + status: "loading", + }); + + useEffect(() => { + let active = true; + + (async () => { + try { + const res = await fetch(`${AUTH_URL}/api/auth/get-session`, { + credentials: "include", + cache: "no-store", + }); + + if (!active) return; + + if (!res.ok) { + setValue({ data: null, status: "unauthenticated" }); + return; + } + + const json = await res.json(); + const user = json?.user; + + if (user?.id) { + setValue({ data: { user }, status: "authenticated" }); + } else { + setValue({ data: null, status: "unauthenticated" }); + } + } catch { + if (active) setValue({ data: null, status: "unauthenticated" }); + } + })(); + + return () => { + active = false; + }; + }, []); + + return ( + + {children} + + ); +} + +/** next-auth-compatible hook returning `{ data, status }`. */ +export function useMacSession(): MacSessionContextValue { + return useContext(MacSessionContext); +} + +/** + * Redirect the browser to the central social sign-in flow. + * `callbackURL` should be an absolute URL back to mploy. + */ +export async function startSocialSignIn( + provider: "google" | "microsoft", + callbackURL: string, +): Promise { + const res = await fetch(`${AUTH_URL}/api/auth/sign-in/social`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider, callbackURL }), + }); + + const json = await res.json(); + if (json?.url) { + window.location.href = json.url; + } else { + throw new Error("Failed to start sign-in"); + } +} + +/** Sign out via the central service, then navigate to `callbackURL`. */ +export async function signOutCentral(callbackURL = "/"): Promise { + try { + await fetch(`${AUTH_URL}/api/auth/sign-out`, { + method: "POST", + credentials: "include", + }); + } finally { + window.location.href = callbackURL; + } +}