Files
Vault/lib/auth-options.ts
T
twotalesanimation a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Fix watch tracking, video unlocking and access-control gaps
Watch progress and unlocking:
- Player samples the playhead and reports the ranges actually played,
  so skipped sections earn no credit and early plays are not lost
- Server merges ranges into compact segment rows; progress never regresses
- Single 80% unlock rule enforced server-side (lib/watch-progress.ts);
  the Unlock button and automatic unlock now agree
- Lock state and progress bar update live from each progress save
- Playlist is derived from the video, so unlocks work for shared
  playlists and for links from watch history and liked videos
- First/previous video decided by playlist position, not index numbers

Access control:
- Video API and HLS/MP4 routes require a signed-in viewer who is
  enrolled and has the video unlocked; thumbnails require sign-in
- Enrolment sync reads course codes from the allow-list, not the request
- /api/admin/meta is admin-only
- Account status, role and levels are re-checked from the database
  during a session, so deactivated students lose access within a minute

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 16:58:07 +02:00

178 lines
4.6 KiB
TypeScript

// lib/auth-options.ts
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "./prisma";
import { normalizeEmail } from "./normalize-email";
import { getAccountStatus } from "./account-status";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
authorization: {
params: {
prompt: "select_account",
},
},
}),
],
pages: {
signIn: "/login",
error: "/login/unauthorized", // Error code passed in query string as ?error=
},
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
cookies: {
sessionToken: {
name: `__Secure-next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
},
},
},
callbacks: {
/**
* SIGN-IN GATE
* This callback MUST NOT mutate users.
* It only decides: allowed or not.
*/
async signIn({ user, account }) {
if (!account || !user?.email) {
return "/login/unauthorized";
}
const normalizedEmail = normalizeEmail(user.email);
if (!normalizedEmail) {
return "/login/unauthorized";
}
/**
* 1️⃣ Enforce: one OAuth account → one user
*/
const existingAccount = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: account.provider,
providerAccountId: account.providerAccountId,
},
},
});
if (existingAccount && existingAccount.userId !== (user as any).id) {
// OAuth account already linked to another user → block
return "/login/unauthorized";
}
/**
* 2️⃣ Check if user already exists
*/
const existingUser = await prisma.user.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: "insensitive",
},
},
});
/**
* 3️⃣ Admins / Superadmins are always allowed
*/
if (
existingUser &&
(existingUser.role === "admin" || existingUser.role === "superadmin")
) {
return true;
}
/**
* 4️⃣ Regular users must be explicitly approved
*/
const allowedStudent = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: "insensitive",
},
},
});
if (!allowedStudent || !allowedStudent.active) {
return "/login/unauthorized";
}
/**
* IMPORTANT:
* - DO NOT create users here
* - DO NOT link accounts here
* PrismaAdapter will create the user on first successful login
*/
return true;
},
/**
* JWT CALLBACK
* Runs on sign-in and again every time the session is read. Role, course
* levels and whether the account is still allowed always come from the
* database, so removing a student takes effect without waiting for their
* 30-day session to expire.
*/
async jwt({ token, user }) {
if (user?.id) token.id = user.id;
const userId = (token.id ?? token.sub) as string | undefined;
if (userId) {
try {
// A fresh sign-in always re-reads the database.
const status = await getAccountStatus(userId, { fresh: !!user });
token.id = userId;
token.role = status.role;
token.levels = status.levels;
token.disabled = !status.allowed;
} catch (err) {
// Database hiccup: keep what the token already says rather than
// signing everyone out.
console.error('[auth] account status check failed', err);
}
}
token.role = token.role ?? "user";
return token;
},
/**
* SESSION CALLBACK
* Only exposes data already trusted in the token. A disabled account gets
* a session with no user, which every route treats as signed out.
*/
async session({ session, token }) {
if (token.disabled) {
return { expires: session.expires };
}
if (session.user) {
(session.user as any).id = token.id ?? null;
(session.user as any).role = token.role ?? "user";
(session.user as any).levels = token.levels ?? "";
}
return session;
},
},
};