299 lines
8.7 KiB
TypeScript
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
|
|
};
|
|
}
|