auth.ts
Rama main · réplica de lectura del repositorio original.
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 });
},
},
});