From d50ab5f7bffa88e1ce884cb3c6f775bbd46c7e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fre=CC=81de=CC=81ric=20Jean?= Date: Wed, 1 Apr 2026 15:17:46 +0200 Subject: [PATCH] first commit --- README.md | 74 ++++++ dist/react/index.d.ts | 135 ++++++++++ dist/react/index.js | 371 ++++++++++++++++++++++++++ dist/react/index.js.map | 1 + dist/server/index.d.ts | 136 ++++++++++ dist/server/index.js | 489 +++++++++++++++++++++++++++++++++++ dist/server/index.js.map | 1 + package.json | 35 +++ react/AuthGuard.tsx | 62 +++++ react/LoginForm.tsx | 163 ++++++++++++ react/PasswordResetForms.tsx | 163 ++++++++++++ react/client.ts | 177 +++++++++++++ react/index.ts | 12 + react/types.ts | 19 ++ server/index.ts | 2 + server/module.ts | 293 +++++++++++++++++++++ server/routes.ts | 381 +++++++++++++++++++++++++++ tsconfig.json | 14 + tsup.config.ts | 26 ++ 19 files changed, 2554 insertions(+) create mode 100644 README.md create mode 100644 dist/react/index.d.ts create mode 100644 dist/react/index.js create mode 100644 dist/react/index.js.map create mode 100644 dist/server/index.d.ts create mode 100644 dist/server/index.js create mode 100644 dist/server/index.js.map create mode 100644 package.json create mode 100644 react/AuthGuard.tsx create mode 100644 react/LoginForm.tsx create mode 100644 react/PasswordResetForms.tsx create mode 100644 react/client.ts create mode 100644 react/index.ts create mode 100644 react/types.ts create mode 100644 server/index.ts create mode 100644 server/module.ts create mode 100644 server/routes.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8da173 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# @packages/auth + +Package d'authentification réutilisable pour projets React + Express. + +## Exports + +- `@packages/auth/react` +- `@packages/auth/server` + +## Contrat Prisma minimal + +Le package serveur suppose un socle Auth.js compatible, avec les modèles suivants: + +- `User` +- `Account` +- `Session` +- `VerificationToken` + +Champs attendus pour le flux local email/mot de passe et reset: + +- `User.id` +- `User.email` +- `User.name` +- `User.image` +- `User.passwordHash` +- `User.emailVerified` +- `Session.sessionToken` +- `Session.userId` +- `Session.expires` +- `VerificationToken.identifier` +- `VerificationToken.token` +- `VerificationToken.expires` + +Le package n'impose pas les champs métier supplémentaires. Ils peuvent rester propres à chaque application. + +## Intégration côté serveur + +Le noyau serveur expose: + +- `createAuthModule` +- `registerAuthApiRoutes` + +Les points configurables importants: + +- `signInPath` +- `authenticatedRedirectPath` +- `messages` +- `passwordReset` +- `onUserRegistered` +- `onSessionValidated` + +## Intégration côté React + +Le package React expose: + +- `createAuthClient` +- `AuthGuard` +- `LoginForm` +- `PasswordResetRequestForm` +- `PasswordResetConfirmForm` + +`createAuthClient` accepte notamment `defaultOAuthCallbackUrl` pour éviter toute dépendance à une route post-login fixe. + +## Build + +```bash +npm run build --workspace @packages/auth +``` + +## Watch + +```bash +npm run dev --workspace @packages/auth +``` diff --git a/dist/react/index.d.ts b/dist/react/index.d.ts new file mode 100644 index 0000000..54738cf --- /dev/null +++ b/dist/react/index.d.ts @@ -0,0 +1,135 @@ +import * as react_jsx_runtime from 'react/jsx-runtime'; +import { ReactNode } from 'react'; + +type AuthGuardProps = { + children: React.ReactNode; + fetchCurrentUser: () => Promise; + redirectTo?: string; + loadingFallback?: React.ReactNode; + authenticatedWrapper?: (children: React.ReactNode) => React.ReactNode; +}; +declare function AuthGuard({ children, fetchCurrentUser, redirectTo, loadingFallback, authenticatedWrapper }: AuthGuardProps): react_jsx_runtime.JSX.Element; + +type AuthProviderKey = "google" | "slack" | (string & {}); +type AuthProviderAvailability = Partial>; +type LoginMode = "signIn" | "register"; +type AuthSubmitValues = { + name: string; + email: string; + password: string; + passwordConfirm: string; +}; +type PasswordResetMode = "reset" | "create"; +type PasswordResetTokenState = { + status: "loading"; +} | { + status: "invalid"; + error: string; +} | { + status: "valid"; + email: string; + mode: PasswordResetMode; +}; + +type CreateAuthClientOptions = { + apiUrl: (path: string) => string; + authUrl?: (path: string) => string; + fetchImpl?: typeof fetch; + credentials?: RequestCredentials; + defaultOAuthCallbackUrl?: string | (() => string); +}; +type LoginInput = { + email: string; + password: string; +}; +type RegisterInput = LoginInput & { + name: string; +}; +declare function createAuthClient(options: CreateAuthClientOptions): { + getProviders(): Promise; + getCurrentUser(): Promise; + register(input: RegisterInput): Promise; + login(input: LoginInput): Promise; + requestPasswordReset(email: string): Promise; + validatePasswordResetToken(token: string): Promise<{ + email: string; + mode: PasswordResetMode; + }>; + confirmPasswordReset(input: { + token: string; + password: string; + }): Promise; + logout(): Promise; + startOAuthSignIn(provider: string, callbackUrl?: string): Promise; +}; + +type LoginFormTexts = { + nameLabel: string; + emailLabel: string; + passwordLabel: string; + passwordConfirmLabel: string; + submitRegisterLabel: string; + submitSignInLabel: string; + toggleToRegisterLabel: string; + toggleToSignInLabel: string; + forgotPasswordLabel: string; + googleLabel: string; + slackLabel: string; +}; +type LoginFormProps = { + mode: LoginMode; + texts: LoginFormTexts; + onSubmit: (values: AuthSubmitValues) => void | Promise; + onModeToggle: () => void; + loading?: boolean; + oauthLoadingProvider?: AuthProviderKey | null; + providers?: AuthProviderAvailability; + onOAuthSignIn?: (provider: AuthProviderKey) => void | Promise; + errorMessage?: string | null; + successMessage?: string | null; + footer?: ReactNode; + forgotPasswordLink?: ReactNode; + emailPlaceholder?: string; + namePlaceholder?: string; +}; +declare function LoginForm({ mode, texts, onSubmit, onModeToggle, loading, oauthLoadingProvider, providers, onOAuthSignIn, errorMessage, successMessage, footer, forgotPasswordLink, emailPlaceholder, namePlaceholder }: LoginFormProps): react_jsx_runtime.JSX.Element; + +type PasswordResetRequestTexts = { + emailLabel: string; + submitLabel: string; + requestSentMessage: string; +}; +type PasswordResetRequestFormProps = { + texts: PasswordResetRequestTexts; + helperText: ReactNode; + loading?: boolean; + requestSent?: boolean; + onSubmit: (values: { + email: string; + }) => void | Promise; + emailPlaceholder?: string; +}; +type PasswordResetConfirmTexts = { + loadingLabel: string; + passwordLabel: string; + passwordConfirmLabel: string; + invalidLinkLabel: string; + resetSubmitLabel: string; + createSubmitLabel: string; + resetSuccessLabel: string; + createSuccessLabel: string; +}; +type PasswordResetConfirmFormProps = { + texts: PasswordResetConfirmTexts; + tokenState: PasswordResetTokenState; + loading?: boolean; + completedMode?: PasswordResetMode | null; + onSubmit: (values: { + password: string; + passwordConfirm: string; + }) => void | Promise; +}; +declare function PasswordResetRequestForm({ texts, helperText, loading, requestSent, onSubmit, emailPlaceholder }: PasswordResetRequestFormProps): react_jsx_runtime.JSX.Element; +declare function PasswordResetConfirmForm({ texts, tokenState, loading, completedMode, onSubmit }: PasswordResetConfirmFormProps): react_jsx_runtime.JSX.Element; + +export { AuthGuard, type AuthProviderAvailability, type AuthProviderKey, type AuthSubmitValues, LoginForm, type LoginMode, PasswordResetConfirmForm, type PasswordResetMode, PasswordResetRequestForm, type PasswordResetTokenState, createAuthClient }; diff --git a/dist/react/index.js b/dist/react/index.js new file mode 100644 index 0000000..4f8e572 --- /dev/null +++ b/dist/react/index.js @@ -0,0 +1,371 @@ +// react/AuthGuard.tsx +import { useEffect, useState } from "react"; +import { Center, Spinner } from "@chakra-ui/react"; +import { Navigate } from "react-router-dom"; +import { Fragment, jsx } from "react/jsx-runtime"; +function AuthGuard({ + children, + fetchCurrentUser, + redirectTo = "/login", + loadingFallback, + authenticatedWrapper +}) { + const [state, setState] = useState({ + loading: true, + authenticated: false + }); + useEffect(() => { + let cancelled = false; + fetchCurrentUser().then(() => { + if (!cancelled) { + setState({ loading: false, authenticated: true }); + } + }).catch(() => { + if (!cancelled) { + setState({ loading: false, authenticated: false }); + } + }); + return () => { + cancelled = true; + }; + }, [fetchCurrentUser]); + if (state.loading) { + return /* @__PURE__ */ jsx(Fragment, { children: loadingFallback ?? /* @__PURE__ */ jsx(Center, { h: "var(--app-height)", children: /* @__PURE__ */ jsx(Spinner, { size: "xl" }) }) }); + } + if (!state.authenticated) { + return /* @__PURE__ */ jsx(Navigate, { to: redirectTo, replace: true }); + } + return /* @__PURE__ */ jsx(Fragment, { children: authenticatedWrapper ? authenticatedWrapper(children) : children }); +} + +// react/client.ts +async function readJsonError(response, fallback) { + const payload = await response.json().catch(() => null); + return new Error(payload?.error ?? fallback); +} +function createAuthClient(options) { + const fetchImpl = options.fetchImpl ?? fetch; + const authUrl = options.authUrl ?? options.apiUrl; + const credentials = options.credentials ?? "include"; + function resolveDefaultOAuthCallbackUrl() { + const configured = options.defaultOAuthCallbackUrl; + if (typeof configured === "function") { + return configured(); + } + if (typeof configured === "string" && configured.trim().length > 0) { + return configured; + } + return `${window.location.origin}/chat`; + } + async function request(path, init) { + return fetchImpl(options.apiUrl(path), { + ...init, + credentials, + headers: { + "Content-Type": "application/json", + ...init?.headers ?? {} + } + }); + } + return { + async getProviders() { + const response = await request("/api/auth/providers"); + if (!response.ok) { + throw await readJsonError(response, "providers_unavailable"); + } + return await response.json(); + }, + async getCurrentUser() { + const response = await request("/api/me"); + if (!response.ok) { + throw await readJsonError(response, "Unauthorized"); + } + const payload = await response.json(); + return payload.user; + }, + async register(input) { + const response = await request("/api/auth/register", { + method: "POST", + body: JSON.stringify(input) + }); + if (!response.ok) { + throw await readJsonError(response, "Registration failed"); + } + }, + async login(input) { + const response = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify(input) + }); + if (!response.ok) { + throw await readJsonError(response, "Sign in failed"); + } + }, + async requestPasswordReset(email) { + const response = await request("/api/auth/password-reset/request", { + method: "POST", + body: JSON.stringify({ email }) + }); + if (!response.ok) { + throw await readJsonError(response, "Password reset request failed"); + } + }, + async validatePasswordResetToken(token) { + const response = await request(`/api/auth/password-reset/validate?token=${encodeURIComponent(token)}`, { + headers: {} + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.email || payload.mode !== "reset" && payload.mode !== "create") { + throw new Error(payload?.error ?? "Invalid reset link"); + } + return { + email: payload.email, + mode: payload.mode + }; + }, + async confirmPasswordReset(input) { + const response = await request("/api/auth/password-reset/confirm", { + method: "POST", + body: JSON.stringify(input) + }); + if (!response.ok) { + throw await readJsonError(response, "Invalid reset link"); + } + }, + async logout() { + const response = await request("/api/auth/logout", { + method: "POST" + }); + if (!response.ok) { + throw await readJsonError(response, "Logout failed"); + } + }, + async startOAuthSignIn(provider, callbackUrl = resolveDefaultOAuthCallbackUrl()) { + const response = await fetchImpl(authUrl("/auth/csrf"), { + credentials + }); + if (!response.ok) { + throw await readJsonError(response, "Sign in failed"); + } + const payload = await response.json(); + if (!payload.csrfToken) { + throw new Error("Sign in failed"); + } + const form = document.createElement("form"); + form.method = "POST"; + form.action = authUrl(`/auth/signin/${provider}`); + form.style.display = "none"; + const csrfInput = document.createElement("input"); + csrfInput.type = "hidden"; + csrfInput.name = "csrfToken"; + csrfInput.value = payload.csrfToken; + form.appendChild(csrfInput); + const callbackInput = document.createElement("input"); + callbackInput.type = "hidden"; + callbackInput.name = "callbackUrl"; + callbackInput.value = callbackUrl; + form.appendChild(callbackInput); + document.body.appendChild(form); + form.submit(); + } + }; +} + +// react/LoginForm.tsx +import { Alert, AlertDescription, AlertIcon, Button, Center as Center2, FormControl, FormLabel, HStack, Icon, Input, Stack } from "@chakra-ui/react"; +import { FcGoogle } from "react-icons/fc"; +import { jsx as jsx2, jsxs } from "react/jsx-runtime"; +function LoginForm({ + mode, + texts, + onSubmit, + onModeToggle, + loading = false, + oauthLoadingProvider = null, + providers, + onOAuthSignIn, + errorMessage, + successMessage, + footer, + forgotPasswordLink, + emailPlaceholder = "you@example.com", + namePlaceholder = "Jane Doe" +}) { + const registerMode = mode === "register"; + function handleSubmit(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void onSubmit({ + name: String(form.get("name") ?? ""), + email: String(form.get("email") ?? ""), + password: String(form.get("password") ?? ""), + passwordConfirm: String(form.get("passwordConfirm") ?? "") + }); + } + return /* @__PURE__ */ jsxs(Stack, { spacing: 5, children: [ + errorMessage ? /* @__PURE__ */ jsxs(Alert, { status: "error", borderRadius: "md", children: [ + /* @__PURE__ */ jsx2(AlertIcon, {}), + /* @__PURE__ */ jsx2(AlertDescription, { children: errorMessage }) + ] }) : null, + successMessage ? /* @__PURE__ */ jsxs(Alert, { status: "success", borderRadius: "md", children: [ + /* @__PURE__ */ jsx2(AlertIcon, {}), + /* @__PURE__ */ jsx2(AlertDescription, { children: successMessage }) + ] }) : null, + /* @__PURE__ */ jsx2("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs(Stack, { spacing: 4, children: [ + registerMode ? /* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [ + /* @__PURE__ */ jsx2(FormLabel, { children: texts.nameLabel }), + /* @__PURE__ */ jsx2(Input, { name: "name", placeholder: namePlaceholder }) + ] }) : null, + /* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [ + /* @__PURE__ */ jsx2(FormLabel, { children: texts.emailLabel }), + /* @__PURE__ */ jsx2(Input, { name: "email", type: "email", placeholder: emailPlaceholder }) + ] }), + /* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [ + /* @__PURE__ */ jsx2(FormLabel, { children: texts.passwordLabel }), + /* @__PURE__ */ jsx2(Input, { name: "password", type: "password", minLength: 8 }) + ] }), + !registerMode && forgotPasswordLink ? /* @__PURE__ */ jsx2(Stack, { align: "flex-end", children: forgotPasswordLink }) : null, + registerMode ? /* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [ + /* @__PURE__ */ jsx2(FormLabel, { children: texts.passwordConfirmLabel }), + /* @__PURE__ */ jsx2(Input, { name: "passwordConfirm", type: "password", minLength: 8 }) + ] }) : null, + /* @__PURE__ */ jsx2(Button, { type: "submit", isLoading: loading, children: registerMode ? texts.submitRegisterLabel : texts.submitSignInLabel }) + ] }) }), + registerMode || !onOAuthSignIn || !providers?.google && !providers?.slack ? null : /* @__PURE__ */ jsxs(HStack, { children: [ + providers.google ? /* @__PURE__ */ jsx2( + Button, + { + flex: 1, + bg: "gray.200", + color: "gray.900", + borderRadius: "full", + justifyContent: "flex-start", + h: "56px", + px: 3, + fontSize: { base: "md", md: "lg" }, + fontWeight: "semibold", + iconSpacing: 4, + leftIcon: /* @__PURE__ */ jsx2(Center2, { boxSize: "40px", bg: "white", borderRadius: "full", boxShadow: "sm", children: /* @__PURE__ */ jsx2(Icon, { as: FcGoogle, boxSize: 6 }) }), + _hover: { bg: "gray.300" }, + _active: { bg: "gray.300" }, + isLoading: oauthLoadingProvider === "google", + onClick: () => void onOAuthSignIn("google"), + children: texts.googleLabel + } + ) : null, + providers.slack ? /* @__PURE__ */ jsx2( + Button, + { + flex: 1, + variant: "outline", + isLoading: oauthLoadingProvider === "slack", + onClick: () => void onOAuthSignIn("slack"), + children: texts.slackLabel + } + ) : null + ] }), + /* @__PURE__ */ jsx2(Button, { variant: "ghost", onClick: onModeToggle, children: registerMode ? texts.toggleToSignInLabel : texts.toggleToRegisterLabel }), + footer ?? null + ] }); +} + +// react/PasswordResetForms.tsx +import { + Alert as Alert2, + AlertDescription as AlertDescription2, + AlertIcon as AlertIcon2, + Button as Button2, + FormControl as FormControl2, + FormLabel as FormLabel2, + Input as Input2, + Spinner as Spinner2, + Stack as Stack2, + Text +} from "@chakra-ui/react"; +import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime"; +function PasswordResetRequestForm({ + texts, + helperText, + loading = false, + requestSent = false, + onSubmit, + emailPlaceholder = "you@example.com" +}) { + function handleSubmit(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void onSubmit({ + email: String(form.get("email") ?? "") + }); + } + return /* @__PURE__ */ jsxs2(Stack2, { spacing: 5, children: [ + requestSent ? /* @__PURE__ */ jsxs2(Alert2, { status: "success", borderRadius: "md", children: [ + /* @__PURE__ */ jsx3(AlertIcon2, {}), + /* @__PURE__ */ jsx3(AlertDescription2, { children: texts.requestSentMessage }) + ] }) : null, + /* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack2, { spacing: 4, children: [ + /* @__PURE__ */ jsxs2(FormControl2, { isRequired: true, children: [ + /* @__PURE__ */ jsx3(FormLabel2, { children: texts.emailLabel }), + /* @__PURE__ */ jsx3(Input2, { name: "email", type: "email", placeholder: emailPlaceholder }) + ] }), + /* @__PURE__ */ jsx3(Text, { fontSize: "sm", color: "gray.600", children: helperText }), + /* @__PURE__ */ jsx3(Button2, { type: "submit", isLoading: loading, children: texts.submitLabel }) + ] }) }) + ] }); +} +function PasswordResetConfirmForm({ + texts, + tokenState, + loading = false, + completedMode = null, + onSubmit +}) { + function handleSubmit(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void onSubmit({ + password: String(form.get("password") ?? ""), + passwordConfirm: String(form.get("passwordConfirm") ?? "") + }); + } + if (tokenState.status === "loading") { + return /* @__PURE__ */ jsxs2(Stack2, { align: "center", py: 6, spacing: 3, children: [ + /* @__PURE__ */ jsx3(Spinner2, {}), + /* @__PURE__ */ jsx3(Text, { color: "gray.600", children: texts.loadingLabel }) + ] }); + } + if (tokenState.status === "invalid") { + return /* @__PURE__ */ jsxs2(Alert2, { status: "error", borderRadius: "md", children: [ + /* @__PURE__ */ jsx3(AlertIcon2, {}), + /* @__PURE__ */ jsx3(AlertDescription2, { children: tokenState.error || texts.invalidLinkLabel }) + ] }); + } + if (completedMode !== null) { + return /* @__PURE__ */ jsxs2(Alert2, { status: "success", borderRadius: "md", children: [ + /* @__PURE__ */ jsx3(AlertIcon2, {}), + /* @__PURE__ */ jsx3(AlertDescription2, { children: completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel }) + ] }); + } + return /* @__PURE__ */ jsxs2(Stack2, { spacing: 4, children: [ + /* @__PURE__ */ jsx3(Text, { fontSize: "sm", color: "gray.600", children: tokenState.email }), + /* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack2, { spacing: 4, children: [ + /* @__PURE__ */ jsxs2(FormControl2, { isRequired: true, children: [ + /* @__PURE__ */ jsx3(FormLabel2, { children: texts.passwordLabel }), + /* @__PURE__ */ jsx3(Input2, { name: "password", type: "password", minLength: 8 }) + ] }), + /* @__PURE__ */ jsxs2(FormControl2, { isRequired: true, children: [ + /* @__PURE__ */ jsx3(FormLabel2, { children: texts.passwordConfirmLabel }), + /* @__PURE__ */ jsx3(Input2, { name: "passwordConfirm", type: "password", minLength: 8 }) + ] }), + /* @__PURE__ */ jsx3(Button2, { type: "submit", isLoading: loading, children: tokenState.mode === "create" ? texts.createSubmitLabel : texts.resetSubmitLabel }) + ] }) }) + ] }); +} +export { + AuthGuard, + LoginForm, + PasswordResetConfirmForm, + PasswordResetRequestForm, + createAuthClient +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/react/index.js.map b/dist/react/index.js.map new file mode 100644 index 0000000..a3ae9b0 --- /dev/null +++ b/dist/react/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../react/AuthGuard.tsx","../../react/client.ts","../../react/LoginForm.tsx","../../react/PasswordResetForms.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { Center, Spinner } from \"@chakra-ui/react\";\nimport { Navigate } from \"react-router-dom\";\n\ntype AuthGuardProps = {\n children: React.ReactNode;\n fetchCurrentUser: () => Promise;\n redirectTo?: string;\n loadingFallback?: React.ReactNode;\n authenticatedWrapper?: (children: React.ReactNode) => React.ReactNode;\n};\n\nexport function AuthGuard({\n children,\n fetchCurrentUser,\n redirectTo = \"/login\",\n loadingFallback,\n authenticatedWrapper\n}: AuthGuardProps) {\n const [state, setState] = useState<{ loading: boolean; authenticated: boolean }>({\n loading: true,\n authenticated: false\n });\n\n useEffect(() => {\n let cancelled = false;\n\n fetchCurrentUser()\n .then(() => {\n if (!cancelled) {\n setState({ loading: false, authenticated: true });\n }\n })\n .catch(() => {\n if (!cancelled) {\n setState({ loading: false, authenticated: false });\n }\n });\n\n return () => {\n cancelled = true;\n };\n }, [fetchCurrentUser]);\n\n if (state.loading) {\n return (\n <>\n {loadingFallback ?? (\n
\n \n
\n )}\n \n );\n }\n\n if (!state.authenticated) {\n return ;\n }\n\n return <>{authenticatedWrapper ? authenticatedWrapper(children) : children};\n}\n","import type { AuthProviderAvailability, PasswordResetMode } from \"./types\";\n\ntype CreateAuthClientOptions = {\n apiUrl: (path: string) => string;\n authUrl?: (path: string) => string;\n fetchImpl?: typeof fetch;\n credentials?: RequestCredentials;\n defaultOAuthCallbackUrl?: string | (() => string);\n};\n\ntype LoginInput = {\n email: string;\n password: string;\n};\n\ntype RegisterInput = LoginInput & {\n name: string;\n};\n\ntype PasswordResetValidationPayload = {\n email?: string;\n mode?: PasswordResetMode;\n error?: string;\n};\n\ntype JsonErrorPayload = {\n error?: string;\n};\n\nasync function readJsonError(response: Response, fallback: string): Promise {\n const payload = (await response.json().catch(() => null)) as JsonErrorPayload | null;\n return new Error(payload?.error ?? fallback);\n}\n\nexport function createAuthClient(options: CreateAuthClientOptions) {\n const fetchImpl = options.fetchImpl ?? fetch;\n const authUrl = options.authUrl ?? options.apiUrl;\n const credentials = options.credentials ?? \"include\";\n\n function resolveDefaultOAuthCallbackUrl(): string {\n const configured = options.defaultOAuthCallbackUrl;\n if (typeof configured === \"function\") {\n return configured();\n }\n if (typeof configured === \"string\" && configured.trim().length > 0) {\n return configured;\n }\n return `${window.location.origin}/chat`;\n }\n\n async function request(path: string, init?: RequestInit): Promise {\n return fetchImpl(options.apiUrl(path), {\n ...init,\n credentials,\n headers: {\n \"Content-Type\": \"application/json\",\n ...(init?.headers ?? {})\n }\n });\n }\n\n return {\n async getProviders(): Promise {\n const response = await request(\"/api/auth/providers\");\n if (!response.ok) {\n throw await readJsonError(response, \"providers_unavailable\");\n }\n return (await response.json()) as AuthProviderAvailability;\n },\n\n async getCurrentUser(): Promise {\n const response = await request(\"/api/me\");\n if (!response.ok) {\n throw await readJsonError(response, \"Unauthorized\");\n }\n const payload = (await response.json()) as { user: TUser };\n return payload.user;\n },\n\n async register(input: RegisterInput): Promise {\n const response = await request(\"/api/auth/register\", {\n method: \"POST\",\n body: JSON.stringify(input)\n });\n if (!response.ok) {\n throw await readJsonError(response, \"Registration failed\");\n }\n },\n\n async login(input: LoginInput): Promise {\n const response = await request(\"/api/auth/login\", {\n method: \"POST\",\n body: JSON.stringify(input)\n });\n if (!response.ok) {\n throw await readJsonError(response, \"Sign in failed\");\n }\n },\n\n async requestPasswordReset(email: string): Promise {\n const response = await request(\"/api/auth/password-reset/request\", {\n method: \"POST\",\n body: JSON.stringify({ email })\n });\n if (!response.ok) {\n throw await readJsonError(response, \"Password reset request failed\");\n }\n },\n\n async validatePasswordResetToken(token: string): Promise<{ email: string; mode: PasswordResetMode }> {\n const response = await request(`/api/auth/password-reset/validate?token=${encodeURIComponent(token)}`, {\n headers: {}\n });\n const payload = (await response.json().catch(() => null)) as PasswordResetValidationPayload | null;\n if (!response.ok || !payload?.email || (payload.mode !== \"reset\" && payload.mode !== \"create\")) {\n throw new Error(payload?.error ?? \"Invalid reset link\");\n }\n return {\n email: payload.email,\n mode: payload.mode\n };\n },\n\n async confirmPasswordReset(input: { token: string; password: string }): Promise {\n const response = await request(\"/api/auth/password-reset/confirm\", {\n method: \"POST\",\n body: JSON.stringify(input)\n });\n if (!response.ok) {\n throw await readJsonError(response, \"Invalid reset link\");\n }\n },\n\n async logout(): Promise {\n const response = await request(\"/api/auth/logout\", {\n method: \"POST\"\n });\n if (!response.ok) {\n throw await readJsonError(response, \"Logout failed\");\n }\n },\n\n async startOAuthSignIn(provider: string, callbackUrl = resolveDefaultOAuthCallbackUrl()): Promise {\n const response = await fetchImpl(authUrl(\"/auth/csrf\"), {\n credentials\n });\n if (!response.ok) {\n throw await readJsonError(response, \"Sign in failed\");\n }\n\n const payload = (await response.json()) as { csrfToken?: string };\n if (!payload.csrfToken) {\n throw new Error(\"Sign in failed\");\n }\n\n const form = document.createElement(\"form\");\n form.method = \"POST\";\n form.action = authUrl(`/auth/signin/${provider}`);\n form.style.display = \"none\";\n\n const csrfInput = document.createElement(\"input\");\n csrfInput.type = \"hidden\";\n csrfInput.name = \"csrfToken\";\n csrfInput.value = payload.csrfToken;\n form.appendChild(csrfInput);\n\n const callbackInput = document.createElement(\"input\");\n callbackInput.type = \"hidden\";\n callbackInput.name = \"callbackUrl\";\n callbackInput.value = callbackUrl;\n form.appendChild(callbackInput);\n\n document.body.appendChild(form);\n form.submit();\n }\n };\n}\n","import type { FormEvent, ReactNode } from \"react\";\nimport { Alert, AlertDescription, AlertIcon, Button, Center, FormControl, FormLabel, HStack, Icon, Input, Stack } from \"@chakra-ui/react\";\nimport { FcGoogle } from \"react-icons/fc\";\nimport type { AuthProviderAvailability, AuthProviderKey, AuthSubmitValues, LoginMode } from \"./types\";\n\ntype LoginFormTexts = {\n nameLabel: string;\n emailLabel: string;\n passwordLabel: string;\n passwordConfirmLabel: string;\n submitRegisterLabel: string;\n submitSignInLabel: string;\n toggleToRegisterLabel: string;\n toggleToSignInLabel: string;\n forgotPasswordLabel: string;\n googleLabel: string;\n slackLabel: string;\n};\n\ntype LoginFormProps = {\n mode: LoginMode;\n texts: LoginFormTexts;\n onSubmit: (values: AuthSubmitValues) => void | Promise;\n onModeToggle: () => void;\n loading?: boolean;\n oauthLoadingProvider?: AuthProviderKey | null;\n providers?: AuthProviderAvailability;\n onOAuthSignIn?: (provider: AuthProviderKey) => void | Promise;\n errorMessage?: string | null;\n successMessage?: string | null;\n footer?: ReactNode;\n forgotPasswordLink?: ReactNode;\n emailPlaceholder?: string;\n namePlaceholder?: string;\n};\n\nexport function LoginForm({\n mode,\n texts,\n onSubmit,\n onModeToggle,\n loading = false,\n oauthLoadingProvider = null,\n providers,\n onOAuthSignIn,\n errorMessage,\n successMessage,\n footer,\n forgotPasswordLink,\n emailPlaceholder = \"you@example.com\",\n namePlaceholder = \"Jane Doe\"\n}: LoginFormProps) {\n const registerMode = mode === \"register\";\n\n function handleSubmit(event: FormEvent) {\n event.preventDefault();\n const form = new FormData(event.currentTarget);\n void onSubmit({\n name: String(form.get(\"name\") ?? \"\"),\n email: String(form.get(\"email\") ?? \"\"),\n password: String(form.get(\"password\") ?? \"\"),\n passwordConfirm: String(form.get(\"passwordConfirm\") ?? \"\")\n });\n }\n\n return (\n \n {errorMessage ? (\n \n \n {errorMessage}\n \n ) : null}\n\n {successMessage ? (\n \n \n {successMessage}\n \n ) : null}\n\n
\n \n {registerMode ? (\n \n {texts.nameLabel}\n \n \n ) : null}\n\n \n {texts.emailLabel}\n \n \n\n \n {texts.passwordLabel}\n \n \n\n {!registerMode && forgotPasswordLink ? {forgotPasswordLink} : null}\n\n {registerMode ? (\n \n {texts.passwordConfirmLabel}\n \n \n ) : null}\n\n \n \n
\n\n {registerMode || !onOAuthSignIn || (!providers?.google && !providers?.slack) ? null : (\n \n {providers.google ? (\n \n \n \n }\n _hover={{ bg: \"gray.300\" }}\n _active={{ bg: \"gray.300\" }}\n isLoading={oauthLoadingProvider === \"google\"}\n onClick={() => void onOAuthSignIn(\"google\")}\n >\n {texts.googleLabel}\n \n ) : null}\n {providers.slack ? (\n void onOAuthSignIn(\"slack\")}\n >\n {texts.slackLabel}\n \n ) : null}\n \n )}\n\n \n\n {footer ?? null}\n
\n );\n}\n","import type { FormEvent, ReactNode } from \"react\";\nimport {\n Alert,\n AlertDescription,\n AlertIcon,\n Button,\n FormControl,\n FormLabel,\n Input,\n Spinner,\n Stack,\n Text\n} from \"@chakra-ui/react\";\nimport type { PasswordResetMode, PasswordResetTokenState } from \"./types\";\n\ntype PasswordResetRequestTexts = {\n emailLabel: string;\n submitLabel: string;\n requestSentMessage: string;\n};\n\ntype PasswordResetRequestFormProps = {\n texts: PasswordResetRequestTexts;\n helperText: ReactNode;\n loading?: boolean;\n requestSent?: boolean;\n onSubmit: (values: { email: string }) => void | Promise;\n emailPlaceholder?: string;\n};\n\ntype PasswordResetConfirmTexts = {\n loadingLabel: string;\n passwordLabel: string;\n passwordConfirmLabel: string;\n invalidLinkLabel: string;\n resetSubmitLabel: string;\n createSubmitLabel: string;\n resetSuccessLabel: string;\n createSuccessLabel: string;\n};\n\ntype PasswordResetConfirmFormProps = {\n texts: PasswordResetConfirmTexts;\n tokenState: PasswordResetTokenState;\n loading?: boolean;\n completedMode?: PasswordResetMode | null;\n onSubmit: (values: { password: string; passwordConfirm: string }) => void | Promise;\n};\n\nexport function PasswordResetRequestForm({\n texts,\n helperText,\n loading = false,\n requestSent = false,\n onSubmit,\n emailPlaceholder = \"you@example.com\"\n}: PasswordResetRequestFormProps) {\n function handleSubmit(event: FormEvent) {\n event.preventDefault();\n const form = new FormData(event.currentTarget);\n void onSubmit({\n email: String(form.get(\"email\") ?? \"\")\n });\n }\n\n return (\n \n {requestSent ? (\n \n \n {texts.requestSentMessage}\n \n ) : null}\n\n
\n \n \n {texts.emailLabel}\n \n \n\n \n {helperText}\n \n\n \n \n
\n
\n );\n}\n\nexport function PasswordResetConfirmForm({\n texts,\n tokenState,\n loading = false,\n completedMode = null,\n onSubmit\n}: PasswordResetConfirmFormProps) {\n function handleSubmit(event: FormEvent) {\n event.preventDefault();\n const form = new FormData(event.currentTarget);\n void onSubmit({\n password: String(form.get(\"password\") ?? \"\"),\n passwordConfirm: String(form.get(\"passwordConfirm\") ?? \"\")\n });\n }\n\n if (tokenState.status === \"loading\") {\n return (\n \n \n {texts.loadingLabel}\n \n );\n }\n\n if (tokenState.status === \"invalid\") {\n return (\n \n \n {tokenState.error || texts.invalidLinkLabel}\n \n );\n }\n\n if (completedMode !== null) {\n return (\n \n \n {completedMode === \"create\" ? texts.createSuccessLabel : texts.resetSuccessLabel}\n \n );\n }\n\n return (\n \n \n {tokenState.email}\n \n\n
\n \n \n {texts.passwordLabel}\n \n \n\n \n {texts.passwordConfirmLabel}\n \n \n\n \n \n
\n
\n );\n}\n"],"mappings":"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","names":["Center","jsx","Alert","AlertDescription","AlertIcon","Button","FormControl","FormLabel","Input","Spinner","Stack","jsx","jsxs"]} \ No newline at end of file diff --git a/dist/server/index.d.ts b/dist/server/index.d.ts new file mode 100644 index 0000000..a3fbd3f --- /dev/null +++ b/dist/server/index.d.ts @@ -0,0 +1,136 @@ +import * as qs from 'qs'; +import * as express_serve_static_core from 'express-serve-static-core'; +import * as express from 'express'; +import { Request, RequestHandler, Express } from 'express'; +import * as _auth_core_adapters from '@auth/core/adapters'; + +type CredentialUser = { + id: string; + name: string | null; + email: string | null; + image?: string | null; + passwordHash: string | null; +}; +type CreateAuthModuleOptions = { + 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; + comparePassword: (password: string, passwordHash: string) => Promise; + sessionUserSelect: Record; + mapSessionUser: (user: any) => TAuthUser; + onSessionValidated?: (user: TAuthUser) => Promise | void; +}; +declare function createAuthModule(options: CreateAuthModuleOptions): { + authConfig: { + adapter: _auth_core_adapters.Adapter; + trustHost: boolean; + debug: boolean; + logger: { + error(error: Error): void; + warn(code: string): void; + debug(message: string, metadata?: unknown): void; + } | undefined; + session: { + strategy: "database"; + }; + secret: string | undefined; + cookies: { + sessionToken: { + name: string; + options: { + httpOnly: boolean; + sameSite: "lax"; + path: string; + secure: boolean; + }; + }; + }; + providers: any[]; + pages: { + signIn: string; + }; + callbacks: { + redirect: ({ url, baseUrl }: { + url: string; + baseUrl: string; + }) => Promise; + }; + }; + authHandler: (req: Request, res: express.Response, next: express.NextFunction) => Promise; + requireSession: RequestHandler>; + extractSessionToken: (cookieHeader: string | undefined) => string | null; + googleAuthEnabled: boolean; + slackAuthEnabled: boolean; +}; + +type RegisterAuthApiRoutesOptions = { + app: Express; + prisma: any; + authHandler: RequestHandler; + requireSession: RequestHandler; + extractSessionToken: (cookieHeader: string | undefined) => string | null; + providersAvailability: Record; + sessionCookieName: string; + sessionCookieSecure: boolean; + extraCookieNamesToClear?: string[]; + messages?: Partial; + authBasePath?: string; + authApiBasePath?: string; + mePath?: string; + normalizeEmail?: (email: string) => string; + passwordHasher?: (password: string) => Promise; + passwordComparator?: (password: string, passwordHash: string) => Promise; + passwordReset?: { + enabled: boolean; + tokenTtlMs?: number; + identifierPrefix?: string; + buildResetUrl: (token: string) => string; + sendMessage: (input: { + user: { + id: string; + email: string; + name: string | null; + passwordHash: string | null; + }; + resetUrl: string; + isPasswordCreation: boolean; + expiresAt: Date; + }) => Promise; + }; + onUserRegistered?: (user: { + id: string; + email: string | null; + name: string | null; + }) => Promise | void; + onPasswordResetConfirmed?: (user: { + id: string; + email: string | null; + name: string | null; + }) => Promise | void; +}; +type AuthRouteMessages = { + invalidPayload: string; + emailAlreadyUsed: string; + accountNotFound: string; + externalAccountOnly: string; + invalidPassword: string; + passwordResetUnavailable: string; + invalidResetLink: string; + expiredResetLink: string; +}; +declare function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void; + +export { createAuthModule, registerAuthApiRoutes }; diff --git a/dist/server/index.js b/dist/server/index.js new file mode 100644 index 0000000..5523374 --- /dev/null +++ b/dist/server/index.js @@ -0,0 +1,489 @@ +// server/module.ts +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 { z } from "zod"; +function parseBoolean(value, fallback) { + if (value === void 0) { + 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; +} +function createAuthModule(options) { + 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 = [ + 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, + 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) { + console.error("[authjs:error]", error.name, error.message, error.cause ?? ""); + }, + warn(code) { + console.warn("[authjs:warn]", code); + }, + debug(message, metadata) { + console.log("[authjs:debug]", message, metadata ?? ""); + } + } : void 0, + session: { strategy: "database" }, + secret: options.authSecret ?? process.env.AUTH_SECRET, + cookies: { + sessionToken: { + name: options.sessionCookieName, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: options.sessionCookieSecure + } + } + }, + providers, + pages: { + signIn: signInPath + }, + callbacks: { + redirect: async ({ url, baseUrl }) => { + const clientOrigin = new URL(options.clientUrl).origin; + const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString(); + const shouldForceChat = (pathname, searchParams) => { + 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 = 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: "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 <= /* @__PURE__ */ new Date()) { + const payload = { 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.authUser = authUser; + await options.onSessionValidated?.(authUser); + next(); + }; + const extractSessionToken = (cookieHeader) => { + 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))]; + }).filter((entry) => 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]; + }).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 + }; +} + +// server/routes.ts +import { createHash, randomBytes } from "crypto"; +import { z as z2 } from "zod"; +var defaultNormalizeEmail = (email) => email.trim(); +var defaultPasswordResetIdentifierPrefix = "password-reset:"; +var defaultMessages = { + invalidPayload: "Invalid payload", + emailAlreadyUsed: "Email already used", + accountNotFound: "Account not found", + externalAccountOnly: "This account uses an external sign-in provider.", + invalidPassword: "Invalid password", + passwordResetUnavailable: "Email service is not configured.", + invalidResetLink: "Invalid reset link", + expiredResetLink: "Invalid or expired reset link" +}; +function hashPasswordResetToken(token) { + return createHash("sha256").update(token).digest("hex"); +} +function buildPasswordResetIdentifier(prefix, userId) { + return `${prefix}${userId}`; +} +function registerAuthApiRoutes(options) { + const authBasePath = options.authBasePath ?? "/auth"; + const authApiBasePath = options.authApiBasePath ?? "/api/auth"; + const mePath = options.mePath ?? "/api/me"; + const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail; + const passwordHasher = options.passwordHasher ?? ((password) => Promise.resolve(password)); + const passwordComparator = options.passwordComparator ?? ((password, hash) => Promise.resolve(password === hash)); + const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix; + const messages = { ...defaultMessages, ...options.messages ?? {} }; + const findUserByEmail = async (email) => { + const normalized = normalizeEmail(email); + const lowered = normalized.toLowerCase(); + return options.prisma.user.findFirst({ + where: { + OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }] + }, + select: { + id: true, + email: true, + name: true, + image: true, + passwordHash: true, + emailVerified: true + } + }); + }; + const getPasswordResetContext = async (rawToken) => { + const verificationToken = await options.prisma.verificationToken.findUnique({ + where: { token: hashPasswordResetToken(rawToken) }, + select: { identifier: true, expires: true } + }); + if (!verificationToken || verificationToken.expires <= /* @__PURE__ */ new Date() || !verificationToken.identifier.startsWith(passwordResetIdentifierPrefix)) { + return null; + } + const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.length); + if (!userId) { + return null; + } + const user = await options.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + passwordHash: true, + emailVerified: true + } + }); + if (!user?.email) { + return null; + } + return { verificationToken, user }; + }; + options.app.use(authBasePath, options.authHandler); + options.app.get(`${authApiBasePath}/providers`, (_req, res) => { + res.json(options.providersAvailability); + }); + options.app.post(`${authApiBasePath}/register`, async (req, res) => { + const parsed = z2.object({ + name: z2.string().min(2).max(60), + email: z2.string().email(), + password: z2.string().min(8) + }).safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + const email = normalizeEmail(parsed.data.email); + const exists = await findUserByEmail(email); + if (exists) { + return res.status(409).json({ error: messages.emailAlreadyUsed }); + } + const passwordHash = await passwordHasher(parsed.data.password); + const created = await options.prisma.user.create({ + data: { + name: parsed.data.name, + email, + passwordHash + }, + select: { id: true, email: true, name: true } + }); + await options.onUserRegistered?.(created); + return res.status(201).json(created); + }); + options.app.post(`${authApiBasePath}/login`, async (req, res) => { + const parsed = z2.object({ + email: z2.string().email(), + password: z2.string().min(8) + }).safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + const email = normalizeEmail(parsed.data.email); + const user = await findUserByEmail(email); + if (!user) { + return res.status(404).json({ error: messages.accountNotFound }); + } + if (!user.passwordHash) { + return res.status(400).json({ error: messages.externalAccountOnly }); + } + const valid = await passwordComparator(parsed.data.password, user.passwordHash); + if (!valid) { + return res.status(401).json({ error: messages.invalidPassword }); + } + const sessionToken = randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3); + await options.prisma.session.create({ + data: { + sessionToken, + userId: user.id, + expires + } + }); + res.cookie(options.sessionCookieName, sessionToken, { + httpOnly: true, + sameSite: "lax", + secure: options.sessionCookieSecure, + path: "/", + expires + }); + return res.status(200).json({ ok: true }); + }); + options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => { + const parsed = z2.object({ + email: z2.string().email() + }).safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + if (!options.passwordReset?.enabled) { + return res.status(503).json({ error: messages.passwordResetUnavailable }); + } + const email = normalizeEmail(parsed.data.email); + const user = await findUserByEmail(email); + if (!user?.email) { + return res.status(200).json({ ok: true }); + } + const rawToken = randomBytes(32).toString("hex"); + const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id); + const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1e3)); + const resetUrl = options.passwordReset.buildResetUrl(rawToken); + const isPasswordCreation = !user.passwordHash; + await options.prisma.verificationToken.deleteMany({ + where: { + OR: [{ identifier }, { expires: { lt: /* @__PURE__ */ new Date() } }] + } + }); + await options.prisma.verificationToken.create({ + data: { + identifier, + token: hashPasswordResetToken(rawToken), + expires: expiresAt + } + }); + await options.passwordReset.sendMessage({ + user: { + id: user.id, + email: user.email, + name: user.name, + passwordHash: user.passwordHash + }, + resetUrl, + isPasswordCreation, + expiresAt + }); + return res.status(200).json({ ok: true }); + }); + options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => { + if (!options.passwordReset?.enabled) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + const parsed = z2.object({ token: z2.string().min(1) }).safeParse({ + token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token + }); + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidResetLink }); + } + const context = await getPasswordResetContext(parsed.data.token); + if (!context) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + return res.status(200).json({ + ok: true, + email: context.user.email, + mode: context.user.passwordHash ? "reset" : "create" + }); + }); + options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => { + if (!options.passwordReset?.enabled) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + const parsed = z2.object({ + token: z2.string().min(1), + password: z2.string().min(8) + }).safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + const context = await getPasswordResetContext(parsed.data.token); + if (!context) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + const passwordHash = await passwordHasher(parsed.data.password); + await options.prisma.$transaction([ + options.prisma.verificationToken.deleteMany({ + where: { identifier: context.verificationToken.identifier } + }), + options.prisma.session.deleteMany({ + where: { userId: context.user.id } + }), + options.prisma.user.update({ + where: { id: context.user.id }, + data: { + passwordHash, + emailVerified: context.user.emailVerified ?? /* @__PURE__ */ new Date() + } + }) + ]); + await options.onPasswordResetConfirmed?.(context.user); + return res.status(200).json({ ok: true }); + }); + options.app.post(`${authApiBasePath}/logout`, async (req, res) => { + const token = options.extractSessionToken(req.headers.cookie); + if (token) { + await options.prisma.session.deleteMany({ where: { sessionToken: token } }); + } + const cookieNamesToClear = [ + options.sessionCookieName, + ...options.extraCookieNamesToClear ?? [], + "authjs.session-token", + "__Secure-authjs.session-token", + "next-auth.session-token", + "__Secure-next-auth.session-token" + ]; + for (const cookieName of cookieNamesToClear) { + res.clearCookie(cookieName, { path: "/" }); + } + return res.status(200).json({ ok: true }); + }); + options.app.get(mePath, options.requireSession, async (req, res) => { + res.json({ user: req.authUser }); + }); +} +export { + createAuthModule, + registerAuthApiRoutes +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/server/index.js.map b/dist/server/index.js.map new file mode 100644 index 0000000..e10f99e --- /dev/null +++ b/dist/server/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../server/module.ts","../../server/routes.ts"],"sourcesContent":["import { ExpressAuth } from \"@auth/express\";\nimport Credentials from \"@auth/express/providers/credentials\";\nimport Google from \"@auth/express/providers/google\";\nimport Slack from \"@auth/express/providers/slack\";\nimport { PrismaAdapter } from \"@auth/prisma-adapter\";\nimport type { Request, RequestHandler } from \"express\";\nimport { z } from \"zod\";\n\ntype CredentialUser = {\n id: string;\n name: string | null;\n email: string | null;\n image?: string | null;\n passwordHash: string | null;\n};\n\ntype CreateAuthModuleOptions = {\n prisma: any;\n clientUrl: string;\n sessionCookieName: string;\n sessionCookieSecure: boolean;\n authUrl?: string;\n authDebug?: boolean;\n authSecret?: string;\n trustHost?: boolean;\n extraSessionCookieNames?: string[];\n signInPath?: string;\n authenticatedRedirectPath?: string;\n googleClientId?: string;\n googleClientSecret?: string;\n slackClientId?: string;\n slackClientSecret?: string;\n findCredentialsUserByEmail: (email: string) => Promise;\n comparePassword: (password: string, passwordHash: string) => Promise;\n sessionUserSelect: Record;\n mapSessionUser: (user: any) => TAuthUser;\n onSessionValidated?: (user: TAuthUser) => Promise | void;\n};\n\nfunction parseBoolean(value: string | undefined, fallback: boolean): boolean {\n if (value === undefined) {\n return fallback;\n }\n const normalized = value.trim().toLowerCase();\n if ([\"1\", \"true\", \"yes\", \"on\"].includes(normalized)) {\n return true;\n }\n if ([\"0\", \"false\", \"no\", \"off\"].includes(normalized)) {\n return false;\n }\n return fallback;\n}\n\nexport function createAuthModule(options: CreateAuthModuleOptions) {\n const signInPath = options.signInPath ?? \"/login\";\n const authenticatedRedirectPath = options.authenticatedRedirectPath ?? \"/chat\";\n const googleAuthEnabled = Boolean(options.googleClientId && options.googleClientSecret);\n const slackAuthEnabled = Boolean(options.slackClientId && options.slackClientSecret);\n\n const providers: any[] = [\n Credentials({\n name: \"Email et mot de passe\",\n credentials: {\n email: { label: \"Email\", type: \"email\" },\n password: { label: \"Password\", type: \"password\" }\n },\n authorize: async (rawCredentials) => {\n const parsed = z\n .object({\n email: z.string().email(),\n password: z.string().min(8)\n })\n .safeParse(rawCredentials);\n\n if (!parsed.success) {\n return null;\n }\n\n const user = await options.findCredentialsUserByEmail(parsed.data.email);\n if (!user?.passwordHash) {\n return null;\n }\n\n const valid = await options.comparePassword(parsed.data.password, user.passwordHash);\n if (!valid) {\n return null;\n }\n\n return {\n id: user.id,\n name: user.name,\n email: user.email,\n image: user.image ?? null\n };\n }\n })\n ];\n\n if (googleAuthEnabled) {\n providers.push(\n Google({\n clientId: options.googleClientId!,\n clientSecret: options.googleClientSecret!,\n allowDangerousEmailAccountLinking: true\n })\n );\n }\n\n if (slackAuthEnabled) {\n providers.push(\n Slack({\n clientId: options.slackClientId!,\n clientSecret: options.slackClientSecret!,\n allowDangerousEmailAccountLinking: true\n })\n );\n }\n\n const authConfig = {\n adapter: PrismaAdapter(options.prisma),\n trustHost: options.trustHost ?? parseBoolean(process.env.AUTH_TRUST_HOST, true),\n debug: options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false),\n logger: options.authDebug\n ? {\n error(error: Error) {\n console.error(\"[authjs:error]\", error.name, error.message, error.cause ?? \"\");\n },\n warn(code: string) {\n console.warn(\"[authjs:warn]\", code);\n },\n debug(message: string, metadata?: unknown) {\n console.log(\"[authjs:debug]\", message, metadata ?? \"\");\n }\n }\n : undefined,\n session: { strategy: \"database\" as const },\n secret: options.authSecret ?? process.env.AUTH_SECRET,\n cookies: {\n sessionToken: {\n name: options.sessionCookieName,\n options: {\n httpOnly: true,\n sameSite: \"lax\" as const,\n path: \"/\",\n secure: options.sessionCookieSecure\n }\n }\n },\n providers,\n pages: {\n signIn: signInPath\n },\n callbacks: {\n redirect: async ({ url, baseUrl }: { url: string; baseUrl: string }) => {\n const clientOrigin = new URL(options.clientUrl).origin;\n const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString();\n\n const shouldForceChat = (pathname: string, searchParams: URLSearchParams): boolean => {\n if (pathname !== \"/\" && pathname !== signInPath) {\n return false;\n }\n return !searchParams.has(\"error\");\n };\n\n if (url.startsWith(\"/\")) {\n const relative = new URL(url, clientOrigin);\n if (shouldForceChat(relative.pathname, relative.searchParams)) {\n return successUrl;\n }\n return `${clientOrigin}${relative.pathname}${relative.search}${relative.hash}`;\n }\n\n try {\n const target = new URL(url);\n const base = new URL(baseUrl);\n\n if (target.origin === clientOrigin) {\n if (shouldForceChat(target.pathname, target.searchParams)) {\n return successUrl;\n }\n return target.toString();\n }\n\n if (target.origin === base.origin) {\n if (shouldForceChat(target.pathname, target.searchParams)) {\n return successUrl;\n }\n return `${clientOrigin}${target.pathname}${target.search}${target.hash}`;\n }\n } catch {\n return successUrl;\n }\n\n return successUrl;\n }\n }\n };\n\n const authHandler = ExpressAuth(authConfig);\n\n const requireSession: RequestHandler = async (req, res, next) => {\n const token = extractSessionToken(req.headers.cookie);\n const authDebug = options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false);\n\n if (!token) {\n const payload: { error: string; reason?: string } = { error: \"Unauthorized\" };\n if (authDebug) {\n payload.reason = \"missing_session_cookie\";\n }\n return res.status(401).json(payload);\n }\n\n const session = await options.prisma.session.findUnique({\n where: { sessionToken: token },\n include: {\n user: {\n select: options.sessionUserSelect\n }\n }\n });\n\n if (!session || session.expires <= new Date()) {\n const payload: { error: string; reason?: string } = { error: \"Unauthorized\" };\n if (authDebug) {\n payload.reason = !session ? \"session_not_found\" : \"session_expired\";\n }\n return res.status(401).json(payload);\n }\n\n const authUser = options.mapSessionUser(session.user);\n (req as Request & { authUser?: TAuthUser }).authUser = authUser;\n await options.onSessionValidated?.(authUser);\n next();\n };\n\n const extractSessionToken = (cookieHeader: string | undefined): string | null => {\n if (!cookieHeader) {\n return null;\n }\n\n const cookies = cookieHeader\n .split(\";\")\n .map((part) => part.trim())\n .map((part) => {\n const index = part.indexOf(\"=\");\n if (index < 0) {\n return null;\n }\n return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] as const;\n })\n .filter((entry): entry is readonly [string, string] => entry !== null);\n\n const possibleNames = [\n options.sessionCookieName,\n ...(options.extraSessionCookieNames ?? []),\n \"__Secure-authjs.session-token\",\n \"authjs.session-token\",\n \"__Secure-next-auth.session-token\",\n \"next-auth.session-token\"\n ];\n\n for (const name of possibleNames) {\n const exact = cookies.find(([cookieName]) => cookieName === name);\n if (exact) {\n return exact[1];\n }\n\n const chunks = cookies\n .filter(([cookieName]) => cookieName.startsWith(`${name}.`))\n .map(([cookieName, value]) => {\n const suffix = cookieName.slice(name.length + 1);\n return [Number.parseInt(suffix, 10), value] as const;\n })\n .filter(([index]) => Number.isInteger(index))\n .sort((left, right) => left[0] - right[0]);\n\n if (chunks.length > 0) {\n return chunks.map(([, value]) => value).join(\"\");\n }\n }\n\n return null;\n };\n\n return {\n authConfig,\n authHandler,\n requireSession,\n extractSessionToken,\n googleAuthEnabled,\n slackAuthEnabled\n };\n}\n","import { createHash, randomBytes } from \"node:crypto\";\nimport type { Express, RequestHandler } from \"express\";\nimport { z } from \"zod\";\n\ntype RegisterAuthApiRoutesOptions = {\n app: Express;\n prisma: any;\n authHandler: RequestHandler;\n requireSession: RequestHandler;\n extractSessionToken: (cookieHeader: string | undefined) => string | null;\n providersAvailability: Record;\n sessionCookieName: string;\n sessionCookieSecure: boolean;\n extraCookieNamesToClear?: string[];\n messages?: Partial;\n authBasePath?: string;\n authApiBasePath?: string;\n mePath?: string;\n normalizeEmail?: (email: string) => string;\n passwordHasher?: (password: string) => Promise;\n passwordComparator?: (password: string, passwordHash: string) => Promise;\n passwordReset?: {\n enabled: boolean;\n tokenTtlMs?: number;\n identifierPrefix?: string;\n buildResetUrl: (token: string) => string;\n sendMessage: (input: {\n user: { id: string; email: string; name: string | null; passwordHash: string | null };\n resetUrl: string;\n isPasswordCreation: boolean;\n expiresAt: Date;\n }) => Promise;\n };\n onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise | void;\n onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise | void;\n};\n\ntype AuthRouteMessages = {\n invalidPayload: string;\n emailAlreadyUsed: string;\n accountNotFound: string;\n externalAccountOnly: string;\n invalidPassword: string;\n passwordResetUnavailable: string;\n invalidResetLink: string;\n expiredResetLink: string;\n};\n\nconst defaultNormalizeEmail = (email: string) => email.trim();\nconst defaultPasswordResetIdentifierPrefix = \"password-reset:\";\nconst defaultMessages: AuthRouteMessages = {\n invalidPayload: \"Invalid payload\",\n emailAlreadyUsed: \"Email already used\",\n accountNotFound: \"Account not found\",\n externalAccountOnly: \"This account uses an external sign-in provider.\",\n invalidPassword: \"Invalid password\",\n passwordResetUnavailable: \"Email service is not configured.\",\n invalidResetLink: \"Invalid reset link\",\n expiredResetLink: \"Invalid or expired reset link\"\n};\n\nfunction hashPasswordResetToken(token: string): string {\n return createHash(\"sha256\").update(token).digest(\"hex\");\n}\n\nfunction buildPasswordResetIdentifier(prefix: string, userId: string): string {\n return `${prefix}${userId}`;\n}\n\nexport function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void {\n const authBasePath = options.authBasePath ?? \"/auth\";\n const authApiBasePath = options.authApiBasePath ?? \"/api/auth\";\n const mePath = options.mePath ?? \"/api/me\";\n const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail;\n const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password));\n const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash));\n const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix;\n const messages = { ...defaultMessages, ...(options.messages ?? {}) };\n\n const findUserByEmail = async (email: string) => {\n const normalized = normalizeEmail(email);\n const lowered = normalized.toLowerCase();\n\n return options.prisma.user.findFirst({\n where: {\n OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }]\n },\n select: {\n id: true,\n email: true,\n name: true,\n image: true,\n passwordHash: true,\n emailVerified: true\n }\n });\n };\n\n const getPasswordResetContext = async (\n rawToken: string\n ): Promise<\n | {\n verificationToken: { identifier: string; expires: Date };\n user: { id: string; email: string | null; name: string | null; passwordHash: string | null; emailVerified: Date | null };\n }\n | null\n > => {\n const verificationToken = await options.prisma.verificationToken.findUnique({\n where: { token: hashPasswordResetToken(rawToken) },\n select: { identifier: true, expires: true }\n });\n\n if (\n !verificationToken ||\n verificationToken.expires <= new Date() ||\n !verificationToken.identifier.startsWith(passwordResetIdentifierPrefix)\n ) {\n return null;\n }\n\n const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.length);\n if (!userId) {\n return null;\n }\n\n const user = await options.prisma.user.findUnique({\n where: { id: userId },\n select: {\n id: true,\n email: true,\n name: true,\n passwordHash: true,\n emailVerified: true\n }\n });\n\n if (!user?.email) {\n return null;\n }\n\n return { verificationToken, user };\n };\n\n options.app.use(authBasePath, options.authHandler);\n\n options.app.get(`${authApiBasePath}/providers`, (_req, res) => {\n res.json(options.providersAvailability);\n });\n\n options.app.post(`${authApiBasePath}/register`, async (req, res) => {\n const parsed = z\n .object({\n name: z.string().min(2).max(60),\n email: z.string().email(),\n password: z.string().min(8)\n })\n .safeParse(req.body);\n\n if (!parsed.success) {\n return res.status(400).json({ error: messages.invalidPayload });\n }\n\n const email = normalizeEmail(parsed.data.email);\n const exists = await findUserByEmail(email);\n if (exists) {\n return res.status(409).json({ error: messages.emailAlreadyUsed });\n }\n\n const passwordHash = await passwordHasher(parsed.data.password);\n const created = await options.prisma.user.create({\n data: {\n name: parsed.data.name,\n email,\n passwordHash\n },\n select: { id: true, email: true, name: true }\n });\n\n await options.onUserRegistered?.(created);\n return res.status(201).json(created);\n });\n\n options.app.post(`${authApiBasePath}/login`, async (req, res) => {\n const parsed = z\n .object({\n email: z.string().email(),\n password: z.string().min(8)\n })\n .safeParse(req.body);\n\n if (!parsed.success) {\n return res.status(400).json({ error: messages.invalidPayload });\n }\n\n const email = normalizeEmail(parsed.data.email);\n const user = await findUserByEmail(email);\n if (!user) {\n return res.status(404).json({ error: messages.accountNotFound });\n }\n\n if (!user.passwordHash) {\n return res.status(400).json({ error: messages.externalAccountOnly });\n }\n\n const valid = await passwordComparator(parsed.data.password, user.passwordHash);\n if (!valid) {\n return res.status(401).json({ error: messages.invalidPassword });\n }\n\n const sessionToken = randomBytes(32).toString(\"hex\");\n const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);\n\n await options.prisma.session.create({\n data: {\n sessionToken,\n userId: user.id,\n expires\n }\n });\n\n res.cookie(options.sessionCookieName, sessionToken, {\n httpOnly: true,\n sameSite: \"lax\",\n secure: options.sessionCookieSecure,\n path: \"/\",\n expires\n });\n\n return res.status(200).json({ ok: true });\n });\n\n options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => {\n const parsed = z\n .object({\n email: z.string().email()\n })\n .safeParse(req.body);\n\n if (!parsed.success) {\n return res.status(400).json({ error: messages.invalidPayload });\n }\n\n if (!options.passwordReset?.enabled) {\n return res.status(503).json({ error: messages.passwordResetUnavailable });\n }\n\n const email = normalizeEmail(parsed.data.email);\n const user = await findUserByEmail(email);\n if (!user?.email) {\n return res.status(200).json({ ok: true });\n }\n\n const rawToken = randomBytes(32).toString(\"hex\");\n const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id);\n const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1000));\n const resetUrl = options.passwordReset.buildResetUrl(rawToken);\n const isPasswordCreation = !user.passwordHash;\n\n await options.prisma.verificationToken.deleteMany({\n where: {\n OR: [{ identifier }, { expires: { lt: new Date() } }]\n }\n });\n\n await options.prisma.verificationToken.create({\n data: {\n identifier,\n token: hashPasswordResetToken(rawToken),\n expires: expiresAt\n }\n });\n\n await options.passwordReset.sendMessage({\n user: {\n id: user.id,\n email: user.email,\n name: user.name,\n passwordHash: user.passwordHash\n },\n resetUrl,\n isPasswordCreation,\n expiresAt\n });\n\n return res.status(200).json({ ok: true });\n });\n\n options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => {\n if (!options.passwordReset?.enabled) {\n return res.status(400).json({ error: messages.expiredResetLink });\n }\n\n const parsed = z.object({ token: z.string().min(1) }).safeParse({\n token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token\n });\n\n if (!parsed.success) {\n return res.status(400).json({ error: messages.invalidResetLink });\n }\n\n const context = await getPasswordResetContext(parsed.data.token);\n if (!context) {\n return res.status(400).json({ error: messages.expiredResetLink });\n }\n\n return res.status(200).json({\n ok: true,\n email: context.user.email,\n mode: context.user.passwordHash ? \"reset\" : \"create\"\n });\n });\n\n options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => {\n if (!options.passwordReset?.enabled) {\n return res.status(400).json({ error: messages.expiredResetLink });\n }\n\n const parsed = z\n .object({\n token: z.string().min(1),\n password: z.string().min(8)\n })\n .safeParse(req.body);\n\n if (!parsed.success) {\n return res.status(400).json({ error: messages.invalidPayload });\n }\n\n const context = await getPasswordResetContext(parsed.data.token);\n if (!context) {\n return res.status(400).json({ error: messages.expiredResetLink });\n }\n\n const passwordHash = await passwordHasher(parsed.data.password);\n await options.prisma.$transaction([\n options.prisma.verificationToken.deleteMany({\n where: { identifier: context.verificationToken.identifier }\n }),\n options.prisma.session.deleteMany({\n where: { userId: context.user.id }\n }),\n options.prisma.user.update({\n where: { id: context.user.id },\n data: {\n passwordHash,\n emailVerified: context.user.emailVerified ?? new Date()\n }\n })\n ]);\n\n await options.onPasswordResetConfirmed?.(context.user);\n return res.status(200).json({ ok: true });\n });\n\n options.app.post(`${authApiBasePath}/logout`, async (req, res) => {\n const token = options.extractSessionToken(req.headers.cookie);\n\n if (token) {\n await options.prisma.session.deleteMany({ where: { sessionToken: token } });\n }\n\n const cookieNamesToClear = [\n options.sessionCookieName,\n ...(options.extraCookieNamesToClear ?? []),\n \"authjs.session-token\",\n \"__Secure-authjs.session-token\",\n \"next-auth.session-token\",\n \"__Secure-next-auth.session-token\"\n ];\n\n for (const cookieName of cookieNamesToClear) {\n res.clearCookie(cookieName, { path: \"/\" });\n }\n\n return res.status(200).json({ ok: true });\n });\n\n options.app.get(mePath, options.requireSession, async (req, res) => {\n res.json({ user: (req as { authUser?: unknown }).authUser });\n });\n}\n"],"mappings":"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","names":["z"]} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..baf6c46 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@packages/auth", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "default": "./dist/server/index.js" + } + }, + "scripts": { + "build": "tsup --config tsup.config.ts", + "dev": "tsup --config tsup.config.ts --watch" + }, + "peerDependencies": { + "@auth/express": "^0.12.1", + "@auth/prisma-adapter": "^2.9.1", + "@chakra-ui/react": "^2.10.7", + "@prisma/client": "^6.5.0", + "express": "^4.21.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-icons": "^5.6.0", + "react-router-dom": "^6.30.0", + "zod": "^3.24.2" + } +} diff --git a/react/AuthGuard.tsx b/react/AuthGuard.tsx new file mode 100644 index 0000000..9f56742 --- /dev/null +++ b/react/AuthGuard.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { Center, Spinner } from "@chakra-ui/react"; +import { Navigate } from "react-router-dom"; + +type AuthGuardProps = { + children: React.ReactNode; + fetchCurrentUser: () => Promise; + redirectTo?: string; + loadingFallback?: React.ReactNode; + authenticatedWrapper?: (children: React.ReactNode) => React.ReactNode; +}; + +export function AuthGuard({ + children, + fetchCurrentUser, + redirectTo = "/login", + loadingFallback, + authenticatedWrapper +}: AuthGuardProps) { + const [state, setState] = useState<{ loading: boolean; authenticated: boolean }>({ + loading: true, + authenticated: false + }); + + useEffect(() => { + let cancelled = false; + + fetchCurrentUser() + .then(() => { + if (!cancelled) { + setState({ loading: false, authenticated: true }); + } + }) + .catch(() => { + if (!cancelled) { + setState({ loading: false, authenticated: false }); + } + }); + + return () => { + cancelled = true; + }; + }, [fetchCurrentUser]); + + if (state.loading) { + return ( + <> + {loadingFallback ?? ( +
+ +
+ )} + + ); + } + + if (!state.authenticated) { + return ; + } + + return <>{authenticatedWrapper ? authenticatedWrapper(children) : children}; +} diff --git a/react/LoginForm.tsx b/react/LoginForm.tsx new file mode 100644 index 0000000..e7cb063 --- /dev/null +++ b/react/LoginForm.tsx @@ -0,0 +1,163 @@ +import type { FormEvent, ReactNode } from "react"; +import { Alert, AlertDescription, AlertIcon, Button, Center, FormControl, FormLabel, HStack, Icon, Input, Stack } from "@chakra-ui/react"; +import { FcGoogle } from "react-icons/fc"; +import type { AuthProviderAvailability, AuthProviderKey, AuthSubmitValues, LoginMode } from "./types"; + +type LoginFormTexts = { + nameLabel: string; + emailLabel: string; + passwordLabel: string; + passwordConfirmLabel: string; + submitRegisterLabel: string; + submitSignInLabel: string; + toggleToRegisterLabel: string; + toggleToSignInLabel: string; + forgotPasswordLabel: string; + googleLabel: string; + slackLabel: string; +}; + +type LoginFormProps = { + mode: LoginMode; + texts: LoginFormTexts; + onSubmit: (values: AuthSubmitValues) => void | Promise; + onModeToggle: () => void; + loading?: boolean; + oauthLoadingProvider?: AuthProviderKey | null; + providers?: AuthProviderAvailability; + onOAuthSignIn?: (provider: AuthProviderKey) => void | Promise; + errorMessage?: string | null; + successMessage?: string | null; + footer?: ReactNode; + forgotPasswordLink?: ReactNode; + emailPlaceholder?: string; + namePlaceholder?: string; +}; + +export function LoginForm({ + mode, + texts, + onSubmit, + onModeToggle, + loading = false, + oauthLoadingProvider = null, + providers, + onOAuthSignIn, + errorMessage, + successMessage, + footer, + forgotPasswordLink, + emailPlaceholder = "you@example.com", + namePlaceholder = "Jane Doe" +}: LoginFormProps) { + const registerMode = mode === "register"; + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void onSubmit({ + name: String(form.get("name") ?? ""), + email: String(form.get("email") ?? ""), + password: String(form.get("password") ?? ""), + passwordConfirm: String(form.get("passwordConfirm") ?? "") + }); + } + + return ( + + {errorMessage ? ( + + + {errorMessage} + + ) : null} + + {successMessage ? ( + + + {successMessage} + + ) : null} + +
+ + {registerMode ? ( + + {texts.nameLabel} + + + ) : null} + + + {texts.emailLabel} + + + + + {texts.passwordLabel} + + + + {!registerMode && forgotPasswordLink ? {forgotPasswordLink} : null} + + {registerMode ? ( + + {texts.passwordConfirmLabel} + + + ) : null} + + + +
+ + {registerMode || !onOAuthSignIn || (!providers?.google && !providers?.slack) ? null : ( + + {providers.google ? ( + + ) : null} + {providers.slack ? ( + + ) : null} + + )} + + + + {footer ?? null} +
+ ); +} diff --git a/react/PasswordResetForms.tsx b/react/PasswordResetForms.tsx new file mode 100644 index 0000000..bea03bc --- /dev/null +++ b/react/PasswordResetForms.tsx @@ -0,0 +1,163 @@ +import type { FormEvent, ReactNode } from "react"; +import { + Alert, + AlertDescription, + AlertIcon, + Button, + FormControl, + FormLabel, + Input, + Spinner, + Stack, + Text +} from "@chakra-ui/react"; +import type { PasswordResetMode, PasswordResetTokenState } from "./types"; + +type PasswordResetRequestTexts = { + emailLabel: string; + submitLabel: string; + requestSentMessage: string; +}; + +type PasswordResetRequestFormProps = { + texts: PasswordResetRequestTexts; + helperText: ReactNode; + loading?: boolean; + requestSent?: boolean; + onSubmit: (values: { email: string }) => void | Promise; + emailPlaceholder?: string; +}; + +type PasswordResetConfirmTexts = { + loadingLabel: string; + passwordLabel: string; + passwordConfirmLabel: string; + invalidLinkLabel: string; + resetSubmitLabel: string; + createSubmitLabel: string; + resetSuccessLabel: string; + createSuccessLabel: string; +}; + +type PasswordResetConfirmFormProps = { + texts: PasswordResetConfirmTexts; + tokenState: PasswordResetTokenState; + loading?: boolean; + completedMode?: PasswordResetMode | null; + onSubmit: (values: { password: string; passwordConfirm: string }) => void | Promise; +}; + +export function PasswordResetRequestForm({ + texts, + helperText, + loading = false, + requestSent = false, + onSubmit, + emailPlaceholder = "you@example.com" +}: PasswordResetRequestFormProps) { + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void onSubmit({ + email: String(form.get("email") ?? "") + }); + } + + return ( + + {requestSent ? ( + + + {texts.requestSentMessage} + + ) : null} + +
+ + + {texts.emailLabel} + + + + + {helperText} + + + + +
+
+ ); +} + +export function PasswordResetConfirmForm({ + texts, + tokenState, + loading = false, + completedMode = null, + onSubmit +}: PasswordResetConfirmFormProps) { + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void onSubmit({ + password: String(form.get("password") ?? ""), + passwordConfirm: String(form.get("passwordConfirm") ?? "") + }); + } + + if (tokenState.status === "loading") { + return ( + + + {texts.loadingLabel} + + ); + } + + if (tokenState.status === "invalid") { + return ( + + + {tokenState.error || texts.invalidLinkLabel} + + ); + } + + if (completedMode !== null) { + return ( + + + {completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel} + + ); + } + + return ( + + + {tokenState.email} + + +
+ + + {texts.passwordLabel} + + + + + {texts.passwordConfirmLabel} + + + + + +
+
+ ); +} diff --git a/react/client.ts b/react/client.ts new file mode 100644 index 0000000..6bc0828 --- /dev/null +++ b/react/client.ts @@ -0,0 +1,177 @@ +import type { AuthProviderAvailability, PasswordResetMode } from "./types"; + +type CreateAuthClientOptions = { + apiUrl: (path: string) => string; + authUrl?: (path: string) => string; + fetchImpl?: typeof fetch; + credentials?: RequestCredentials; + defaultOAuthCallbackUrl?: string | (() => string); +}; + +type LoginInput = { + email: string; + password: string; +}; + +type RegisterInput = LoginInput & { + name: string; +}; + +type PasswordResetValidationPayload = { + email?: string; + mode?: PasswordResetMode; + error?: string; +}; + +type JsonErrorPayload = { + error?: string; +}; + +async function readJsonError(response: Response, fallback: string): Promise { + const payload = (await response.json().catch(() => null)) as JsonErrorPayload | null; + return new Error(payload?.error ?? fallback); +} + +export function createAuthClient(options: CreateAuthClientOptions) { + const fetchImpl = options.fetchImpl ?? fetch; + const authUrl = options.authUrl ?? options.apiUrl; + const credentials = options.credentials ?? "include"; + + function resolveDefaultOAuthCallbackUrl(): string { + const configured = options.defaultOAuthCallbackUrl; + if (typeof configured === "function") { + return configured(); + } + if (typeof configured === "string" && configured.trim().length > 0) { + return configured; + } + return `${window.location.origin}/chat`; + } + + async function request(path: string, init?: RequestInit): Promise { + return fetchImpl(options.apiUrl(path), { + ...init, + credentials, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}) + } + }); + } + + return { + async getProviders(): Promise { + const response = await request("/api/auth/providers"); + if (!response.ok) { + throw await readJsonError(response, "providers_unavailable"); + } + return (await response.json()) as AuthProviderAvailability; + }, + + async getCurrentUser(): Promise { + const response = await request("/api/me"); + if (!response.ok) { + throw await readJsonError(response, "Unauthorized"); + } + const payload = (await response.json()) as { user: TUser }; + return payload.user; + }, + + async register(input: RegisterInput): Promise { + const response = await request("/api/auth/register", { + method: "POST", + body: JSON.stringify(input) + }); + if (!response.ok) { + throw await readJsonError(response, "Registration failed"); + } + }, + + async login(input: LoginInput): Promise { + const response = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify(input) + }); + if (!response.ok) { + throw await readJsonError(response, "Sign in failed"); + } + }, + + async requestPasswordReset(email: string): Promise { + const response = await request("/api/auth/password-reset/request", { + method: "POST", + body: JSON.stringify({ email }) + }); + if (!response.ok) { + throw await readJsonError(response, "Password reset request failed"); + } + }, + + async validatePasswordResetToken(token: string): Promise<{ email: string; mode: PasswordResetMode }> { + const response = await request(`/api/auth/password-reset/validate?token=${encodeURIComponent(token)}`, { + headers: {} + }); + const payload = (await response.json().catch(() => null)) as PasswordResetValidationPayload | null; + if (!response.ok || !payload?.email || (payload.mode !== "reset" && payload.mode !== "create")) { + throw new Error(payload?.error ?? "Invalid reset link"); + } + return { + email: payload.email, + mode: payload.mode + }; + }, + + async confirmPasswordReset(input: { token: string; password: string }): Promise { + const response = await request("/api/auth/password-reset/confirm", { + method: "POST", + body: JSON.stringify(input) + }); + if (!response.ok) { + throw await readJsonError(response, "Invalid reset link"); + } + }, + + async logout(): Promise { + const response = await request("/api/auth/logout", { + method: "POST" + }); + if (!response.ok) { + throw await readJsonError(response, "Logout failed"); + } + }, + + async startOAuthSignIn(provider: string, callbackUrl = resolveDefaultOAuthCallbackUrl()): Promise { + const response = await fetchImpl(authUrl("/auth/csrf"), { + credentials + }); + if (!response.ok) { + throw await readJsonError(response, "Sign in failed"); + } + + const payload = (await response.json()) as { csrfToken?: string }; + if (!payload.csrfToken) { + throw new Error("Sign in failed"); + } + + const form = document.createElement("form"); + form.method = "POST"; + form.action = authUrl(`/auth/signin/${provider}`); + form.style.display = "none"; + + const csrfInput = document.createElement("input"); + csrfInput.type = "hidden"; + csrfInput.name = "csrfToken"; + csrfInput.value = payload.csrfToken; + form.appendChild(csrfInput); + + const callbackInput = document.createElement("input"); + callbackInput.type = "hidden"; + callbackInput.name = "callbackUrl"; + callbackInput.value = callbackUrl; + form.appendChild(callbackInput); + + document.body.appendChild(form); + form.submit(); + } + }; +} diff --git a/react/index.ts b/react/index.ts new file mode 100644 index 0000000..499405d --- /dev/null +++ b/react/index.ts @@ -0,0 +1,12 @@ +export { AuthGuard } from "./AuthGuard"; +export { createAuthClient } from "./client"; +export { LoginForm } from "./LoginForm"; +export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms"; +export type { + AuthProviderAvailability, + AuthProviderKey, + AuthSubmitValues, + LoginMode, + PasswordResetMode, + PasswordResetTokenState +} from "./types"; diff --git a/react/types.ts b/react/types.ts new file mode 100644 index 0000000..790a1d8 --- /dev/null +++ b/react/types.ts @@ -0,0 +1,19 @@ +export type AuthProviderKey = "google" | "slack" | (string & {}); + +export type AuthProviderAvailability = Partial>; + +export type LoginMode = "signIn" | "register"; + +export type AuthSubmitValues = { + name: string; + email: string; + password: string; + passwordConfirm: string; +}; + +export type PasswordResetMode = "reset" | "create"; + +export type PasswordResetTokenState = + | { status: "loading" } + | { status: "invalid"; error: string } + | { status: "valid"; email: string; mode: PasswordResetMode }; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..c319e7b --- /dev/null +++ b/server/index.ts @@ -0,0 +1,2 @@ +export { createAuthModule } from "./module.js"; +export { registerAuthApiRoutes } from "./routes.js"; diff --git a/server/module.ts b/server/module.ts new file mode 100644 index 0000000..7739eab --- /dev/null +++ b/server/module.ts @@ -0,0 +1,293 @@ +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 = { + 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; + comparePassword: (password: string, passwordHash: string) => Promise; + sessionUserSelect: Record; + mapSessionUser: (user: any) => TAuthUser; + onSessionValidated?: (user: TAuthUser) => Promise | 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(options: CreateAuthModuleOptions) { + 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!, + 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 + }; +} diff --git a/server/routes.ts b/server/routes.ts new file mode 100644 index 0000000..ec5261c --- /dev/null +++ b/server/routes.ts @@ -0,0 +1,381 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { Express, RequestHandler } from "express"; +import { z } from "zod"; + +type RegisterAuthApiRoutesOptions = { + app: Express; + prisma: any; + authHandler: RequestHandler; + requireSession: RequestHandler; + extractSessionToken: (cookieHeader: string | undefined) => string | null; + providersAvailability: Record; + sessionCookieName: string; + sessionCookieSecure: boolean; + extraCookieNamesToClear?: string[]; + messages?: Partial; + authBasePath?: string; + authApiBasePath?: string; + mePath?: string; + normalizeEmail?: (email: string) => string; + passwordHasher?: (password: string) => Promise; + passwordComparator?: (password: string, passwordHash: string) => Promise; + passwordReset?: { + enabled: boolean; + tokenTtlMs?: number; + identifierPrefix?: string; + buildResetUrl: (token: string) => string; + sendMessage: (input: { + user: { id: string; email: string; name: string | null; passwordHash: string | null }; + resetUrl: string; + isPasswordCreation: boolean; + expiresAt: Date; + }) => Promise; + }; + onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise | void; + onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise | void; +}; + +type AuthRouteMessages = { + invalidPayload: string; + emailAlreadyUsed: string; + accountNotFound: string; + externalAccountOnly: string; + invalidPassword: string; + passwordResetUnavailable: string; + invalidResetLink: string; + expiredResetLink: string; +}; + +const defaultNormalizeEmail = (email: string) => email.trim(); +const defaultPasswordResetIdentifierPrefix = "password-reset:"; +const defaultMessages: AuthRouteMessages = { + invalidPayload: "Invalid payload", + emailAlreadyUsed: "Email already used", + accountNotFound: "Account not found", + externalAccountOnly: "This account uses an external sign-in provider.", + invalidPassword: "Invalid password", + passwordResetUnavailable: "Email service is not configured.", + invalidResetLink: "Invalid reset link", + expiredResetLink: "Invalid or expired reset link" +}; + +function hashPasswordResetToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function buildPasswordResetIdentifier(prefix: string, userId: string): string { + return `${prefix}${userId}`; +} + +export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void { + const authBasePath = options.authBasePath ?? "/auth"; + const authApiBasePath = options.authApiBasePath ?? "/api/auth"; + const mePath = options.mePath ?? "/api/me"; + const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail; + const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password)); + const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash)); + const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix; + const messages = { ...defaultMessages, ...(options.messages ?? {}) }; + + const findUserByEmail = async (email: string) => { + const normalized = normalizeEmail(email); + const lowered = normalized.toLowerCase(); + + return options.prisma.user.findFirst({ + where: { + OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }] + }, + select: { + id: true, + email: true, + name: true, + image: true, + passwordHash: true, + emailVerified: true + } + }); + }; + + const getPasswordResetContext = async ( + rawToken: string + ): Promise< + | { + verificationToken: { identifier: string; expires: Date }; + user: { id: string; email: string | null; name: string | null; passwordHash: string | null; emailVerified: Date | null }; + } + | null + > => { + const verificationToken = await options.prisma.verificationToken.findUnique({ + where: { token: hashPasswordResetToken(rawToken) }, + select: { identifier: true, expires: true } + }); + + if ( + !verificationToken || + verificationToken.expires <= new Date() || + !verificationToken.identifier.startsWith(passwordResetIdentifierPrefix) + ) { + return null; + } + + const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.length); + if (!userId) { + return null; + } + + const user = await options.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + passwordHash: true, + emailVerified: true + } + }); + + if (!user?.email) { + return null; + } + + return { verificationToken, user }; + }; + + options.app.use(authBasePath, options.authHandler); + + options.app.get(`${authApiBasePath}/providers`, (_req, res) => { + res.json(options.providersAvailability); + }); + + options.app.post(`${authApiBasePath}/register`, async (req, res) => { + const parsed = z + .object({ + name: z.string().min(2).max(60), + email: z.string().email(), + password: z.string().min(8) + }) + .safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + + const email = normalizeEmail(parsed.data.email); + const exists = await findUserByEmail(email); + if (exists) { + return res.status(409).json({ error: messages.emailAlreadyUsed }); + } + + const passwordHash = await passwordHasher(parsed.data.password); + const created = await options.prisma.user.create({ + data: { + name: parsed.data.name, + email, + passwordHash + }, + select: { id: true, email: true, name: true } + }); + + await options.onUserRegistered?.(created); + return res.status(201).json(created); + }); + + options.app.post(`${authApiBasePath}/login`, async (req, res) => { + const parsed = z + .object({ + email: z.string().email(), + password: z.string().min(8) + }) + .safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + + const email = normalizeEmail(parsed.data.email); + const user = await findUserByEmail(email); + if (!user) { + return res.status(404).json({ error: messages.accountNotFound }); + } + + if (!user.passwordHash) { + return res.status(400).json({ error: messages.externalAccountOnly }); + } + + const valid = await passwordComparator(parsed.data.password, user.passwordHash); + if (!valid) { + return res.status(401).json({ error: messages.invalidPassword }); + } + + const sessionToken = randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + await options.prisma.session.create({ + data: { + sessionToken, + userId: user.id, + expires + } + }); + + res.cookie(options.sessionCookieName, sessionToken, { + httpOnly: true, + sameSite: "lax", + secure: options.sessionCookieSecure, + path: "/", + expires + }); + + return res.status(200).json({ ok: true }); + }); + + options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => { + const parsed = z + .object({ + email: z.string().email() + }) + .safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + + if (!options.passwordReset?.enabled) { + return res.status(503).json({ error: messages.passwordResetUnavailable }); + } + + const email = normalizeEmail(parsed.data.email); + const user = await findUserByEmail(email); + if (!user?.email) { + return res.status(200).json({ ok: true }); + } + + const rawToken = randomBytes(32).toString("hex"); + const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id); + const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1000)); + const resetUrl = options.passwordReset.buildResetUrl(rawToken); + const isPasswordCreation = !user.passwordHash; + + await options.prisma.verificationToken.deleteMany({ + where: { + OR: [{ identifier }, { expires: { lt: new Date() } }] + } + }); + + await options.prisma.verificationToken.create({ + data: { + identifier, + token: hashPasswordResetToken(rawToken), + expires: expiresAt + } + }); + + await options.passwordReset.sendMessage({ + user: { + id: user.id, + email: user.email, + name: user.name, + passwordHash: user.passwordHash + }, + resetUrl, + isPasswordCreation, + expiresAt + }); + + return res.status(200).json({ ok: true }); + }); + + options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => { + if (!options.passwordReset?.enabled) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + + const parsed = z.object({ token: z.string().min(1) }).safeParse({ + token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token + }); + + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidResetLink }); + } + + const context = await getPasswordResetContext(parsed.data.token); + if (!context) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + + return res.status(200).json({ + ok: true, + email: context.user.email, + mode: context.user.passwordHash ? "reset" : "create" + }); + }); + + options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => { + if (!options.passwordReset?.enabled) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + + const parsed = z + .object({ + token: z.string().min(1), + password: z.string().min(8) + }) + .safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ error: messages.invalidPayload }); + } + + const context = await getPasswordResetContext(parsed.data.token); + if (!context) { + return res.status(400).json({ error: messages.expiredResetLink }); + } + + const passwordHash = await passwordHasher(parsed.data.password); + await options.prisma.$transaction([ + options.prisma.verificationToken.deleteMany({ + where: { identifier: context.verificationToken.identifier } + }), + options.prisma.session.deleteMany({ + where: { userId: context.user.id } + }), + options.prisma.user.update({ + where: { id: context.user.id }, + data: { + passwordHash, + emailVerified: context.user.emailVerified ?? new Date() + } + }) + ]); + + await options.onPasswordResetConfirmed?.(context.user); + return res.status(200).json({ ok: true }); + }); + + options.app.post(`${authApiBasePath}/logout`, async (req, res) => { + const token = options.extractSessionToken(req.headers.cookie); + + if (token) { + await options.prisma.session.deleteMany({ where: { sessionToken: token } }); + } + + const cookieNamesToClear = [ + options.sessionCookieName, + ...(options.extraCookieNamesToClear ?? []), + "authjs.session-token", + "__Secure-authjs.session-token", + "next-auth.session-token", + "__Secure-next-auth.session-token" + ]; + + for (const cookieName of cookieNamesToClear) { + res.clearCookie(cookieName, { path: "/" }); + } + + return res.status(200).json({ ok: true }); + }); + + options.app.get(mePath, options.requireSession, async (req, res) => { + res.json({ user: (req as { authUser?: unknown }).authUser }); + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..35a3612 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "declaration": true, + "emitDeclarationOnly": false, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["react", "server"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..8b7a559 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + tsconfig: "tsconfig.json", + entry: { + "react/index": "react/index.ts", + "server/index": "server/index.ts" + }, + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + outDir: "dist", + external: [ + "@auth/express", + "@auth/prisma-adapter", + "@chakra-ui/react", + "@prisma/client", + "express", + "react", + "react-dom", + "react-icons", + "react-router-dom", + "zod" + ] +});