import NextAuth, { CredentialsSignin } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { encode as defaultEncode } from "next-auth/jwt"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { users } from "@/lib/db/schema"; import { getClientIp } from "@/lib/rate-limit"; import { authenticateWithPassword } from "@/lib/auth/password-login"; import { consumeLoginTicket } from "@/lib/auth/login-tickets"; import { recordActivity } from "@/lib/users/last-active"; const REMEMBER_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const DEFAULT_MAX_AGE = 60 * 60 * 24; // 1 day when "keep me signed in" is unchecked export class RateLimitedSignin extends CredentialsSignin { code = "rate_limited"; } export const { handlers, signIn, signOut, auth } = NextAuth({ session: { strategy: "jwt", maxAge: REMEMBER_MAX_AGE }, pages: { signIn: "/login", }, providers: [ Credentials({ credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" }, remember: { label: "Remember me", type: "text" }, }, authorize: async (credentials, request) => { const username = typeof credentials?.username === "string" ? credentials.username : undefined; const password = typeof credentials?.password === "string" ? credentials.password : undefined; if (!username || !password) return null; // Shared with POST /api/v1/auth/login — same rate-limit buckets. const result = await authenticateWithPassword(username, password, getClientIp(request)); if (!result.ok) { if (result.reason === "rate_limited") throw new RateLimitedSignin(); return null; } const { user } = result; return { id: user.id, username: user.username, name: user.displayName ?? undefined, rememberMe: credentials?.remember === "on", role: user.role, }; }, }), // Plex/Jellyfin sign-in (lib/auth/media-signin.ts). The server checks the // person with Plex or Jellyfin first, then issues a one-time login ticket // for the account it settled on; this provider only trades that ticket // for a session. A ticket is 256 random bits, lives 2 minutes, and is // gone the moment it's used — see lib/auth/login-tickets.ts. Credentials({ id: "media-server", name: "Plex or Jellyfin", credentials: { ticket: { label: "Ticket", type: "text" }, remember: { label: "Remember me", type: "text" }, }, authorize: async (credentials) => { const userId = consumeLoginTicket(credentials?.ticket); if (!userId) return null; const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) return null; return { id: user.id, username: user.username, name: user.displayName ?? undefined, rememberMe: credentials?.remember === "on", role: user.role, }; }, }), ], callbacks: { async jwt({ token, user }) { if (user) { token.userId = user.id; token.username = user.username; token.rememberMe = user.rememberMe ?? false; token.role = user.role; token.signedInAt = Date.now(); return token; } if (!token.userId) return token; // Checked on every request rather than trusted from the 30-day JWT, // whichever provider signed the browser in (password, or a Plex/Jellyfin // ticket — both leave the account id in token.userId): a removed member, or anyone signed in before their password was // changed, loses their browser session right away (returning null // clears the cookie) — the same moment their API tokens are revoked. // Role is read fresh here too, since it can change after sign-in // (promotion, demotion, the admin-pinning migration). const [row] = await db .select({ role: users.role, permissions: users.permissions, passwordChangedAt: users.passwordChangedAt, lastActiveAt: users.lastActiveAt, language: users.language, }) .from(users) .where(eq(users.id, token.userId as string)) .limit(1); if (!row) return null; // Tokens from before this field existed have no signedInAt; fall back // to when the JWT was issued. const signedInAt = typeof token.signedInAt === "number" ? token.signedInAt : (token.iat ?? 0) * 1000; if (row.passwordChangedAt && row.passwordChangedAt.getTime() > signedInAt) return null; token.role = row.role; // Permissions likewise (lib/users/permissions.ts): the admin changing // a member's switches applies on their very next page load. token.permissions = row.permissions; // And the language they chose (lib/i18n): a change shows on the next // page load, on every device. token.language = row.language; recordActivity(token.userId as string, row.lastActiveAt); return token; }, session({ session, token }) { if (session.user && token.userId) { session.user.id = token.userId as string; session.user.username = token.username as string; session.user.role = (token.role as typeof session.user.role) ?? "member"; session.user.permissions = Array.isArray(token.permissions) ? token.permissions : []; session.user.language = typeof token.language === "string" ? token.language : null; } return session; }, }, jwt: { encode: async (params) => { if (!params.token) return defaultEncode(params); const maxAge = params.token.rememberMe === false ? DEFAULT_MAX_AGE : REMEMBER_MAX_AGE; return defaultEncode({ ...params, maxAge }); }, }, });