Files
lib-auth/server/module.ts

299 lines
8.7 KiB
TypeScript

import { ExpressAuth } from "@auth/express";
import Credentials from "@auth/express/providers/credentials";
import Google from "@auth/express/providers/google";
import Slack from "@auth/express/providers/slack";
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { Request, RequestHandler } from "express";
import { z } from "zod";
type CredentialUser = {
id: string;
name: string | null;
email: string | null;
image?: string | null;
passwordHash: string | null;
};
type CreateAuthModuleOptions<TAuthUser> = {
prisma: any;
clientUrl: string;
sessionCookieName: string;
sessionCookieSecure: boolean;
authUrl?: string;
authDebug?: boolean;
authSecret?: string;
trustHost?: boolean;
extraSessionCookieNames?: string[];
signInPath?: string;
authenticatedRedirectPath?: string;
googleClientId?: string;
googleClientSecret?: string;
slackClientId?: string;
slackClientSecret?: string;
findCredentialsUserByEmail: (email: string) => Promise<CredentialUser | null>;
comparePassword: (password: string, passwordHash: string) => Promise<boolean>;
sessionUserSelect: Record<string, boolean>;
mapSessionUser: (user: any) => TAuthUser;
onSessionValidated?: (user: TAuthUser) => Promise<void> | void;
};
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return fallback;
}
export function createAuthModule<TAuthUser>(options: CreateAuthModuleOptions<TAuthUser>) {
const signInPath = options.signInPath ?? "/login";
const authenticatedRedirectPath = options.authenticatedRedirectPath ?? "/chat";
const googleAuthEnabled = Boolean(options.googleClientId && options.googleClientSecret);
const slackAuthEnabled = Boolean(options.slackClientId && options.slackClientSecret);
const providers: any[] = [
Credentials({
name: "Email et mot de passe",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
authorize: async (rawCredentials) => {
const parsed = z
.object({
email: z.string().email(),
password: z.string().min(8)
})
.safeParse(rawCredentials);
if (!parsed.success) {
return null;
}
const user = await options.findCredentialsUserByEmail(parsed.data.email);
if (!user?.passwordHash) {
return null;
}
const valid = await options.comparePassword(parsed.data.password, user.passwordHash);
if (!valid) {
return null;
}
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image ?? null
};
}
})
];
if (googleAuthEnabled) {
providers.push(
Google({
clientId: options.googleClientId!,
clientSecret: options.googleClientSecret!,
authorization: {
params: {
prompt: "select_account"
}
},
allowDangerousEmailAccountLinking: true
})
);
}
if (slackAuthEnabled) {
providers.push(
Slack({
clientId: options.slackClientId!,
clientSecret: options.slackClientSecret!,
allowDangerousEmailAccountLinking: true
})
);
}
const authConfig = {
adapter: PrismaAdapter(options.prisma),
trustHost: options.trustHost ?? parseBoolean(process.env.AUTH_TRUST_HOST, true),
debug: options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false),
logger: options.authDebug
? {
error(error: Error) {
console.error("[authjs:error]", error.name, error.message, error.cause ?? "");
},
warn(code: string) {
console.warn("[authjs:warn]", code);
},
debug(message: string, metadata?: unknown) {
console.log("[authjs:debug]", message, metadata ?? "");
}
}
: undefined,
session: { strategy: "database" as const },
secret: options.authSecret ?? process.env.AUTH_SECRET,
cookies: {
sessionToken: {
name: options.sessionCookieName,
options: {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: options.sessionCookieSecure
}
}
},
providers,
pages: {
signIn: signInPath
},
callbacks: {
redirect: async ({ url, baseUrl }: { url: string; baseUrl: string }) => {
const clientOrigin = new URL(options.clientUrl).origin;
const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString();
const shouldForceChat = (pathname: string, searchParams: URLSearchParams): boolean => {
if (pathname !== "/" && pathname !== signInPath) {
return false;
}
return !searchParams.has("error");
};
if (url.startsWith("/")) {
const relative = new URL(url, clientOrigin);
if (shouldForceChat(relative.pathname, relative.searchParams)) {
return successUrl;
}
return `${clientOrigin}${relative.pathname}${relative.search}${relative.hash}`;
}
try {
const target = new URL(url);
const base = new URL(baseUrl);
if (target.origin === clientOrigin) {
if (shouldForceChat(target.pathname, target.searchParams)) {
return successUrl;
}
return target.toString();
}
if (target.origin === base.origin) {
if (shouldForceChat(target.pathname, target.searchParams)) {
return successUrl;
}
return `${clientOrigin}${target.pathname}${target.search}${target.hash}`;
}
} catch {
return successUrl;
}
return successUrl;
}
}
};
const authHandler = ExpressAuth(authConfig);
const requireSession: RequestHandler = async (req, res, next) => {
const token = extractSessionToken(req.headers.cookie);
const authDebug = options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false);
if (!token) {
const payload: { error: string; reason?: string } = { error: "Unauthorized" };
if (authDebug) {
payload.reason = "missing_session_cookie";
}
return res.status(401).json(payload);
}
const session = await options.prisma.session.findUnique({
where: { sessionToken: token },
include: {
user: {
select: options.sessionUserSelect
}
}
});
if (!session || session.expires <= new Date()) {
const payload: { error: string; reason?: string } = { error: "Unauthorized" };
if (authDebug) {
payload.reason = !session ? "session_not_found" : "session_expired";
}
return res.status(401).json(payload);
}
const authUser = options.mapSessionUser(session.user);
(req as Request & { authUser?: TAuthUser }).authUser = authUser;
await options.onSessionValidated?.(authUser);
next();
};
const extractSessionToken = (cookieHeader: string | undefined): string | null => {
if (!cookieHeader) {
return null;
}
const cookies = cookieHeader
.split(";")
.map((part) => part.trim())
.map((part) => {
const index = part.indexOf("=");
if (index < 0) {
return null;
}
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] as const;
})
.filter((entry): entry is readonly [string, string] => entry !== null);
const possibleNames = [
options.sessionCookieName,
...(options.extraSessionCookieNames ?? []),
"__Secure-authjs.session-token",
"authjs.session-token",
"__Secure-next-auth.session-token",
"next-auth.session-token"
];
for (const name of possibleNames) {
const exact = cookies.find(([cookieName]) => cookieName === name);
if (exact) {
return exact[1];
}
const chunks = cookies
.filter(([cookieName]) => cookieName.startsWith(`${name}.`))
.map(([cookieName, value]) => {
const suffix = cookieName.slice(name.length + 1);
return [Number.parseInt(suffix, 10), value] as const;
})
.filter(([index]) => Number.isInteger(index))
.sort((left, right) => left[0] - right[0]);
if (chunks.length > 0) {
return chunks.map(([, value]) => value).join("");
}
}
return null;
};
return {
authConfig,
authHandler,
requireSession,
extractSessionToken,
googleAuthEnabled,
slackAuthEnabled
};
}