pilcrowOnPaper / oslo

A collection of auth-related utilities
https://oslo.js.org
MIT License
993 stars 32 forks source link

Unable to use Argon2id from "oslo/password" inside server action - Next.js #81

Open srkuleo opened 2 months ago

srkuleo commented 2 months ago

Current version: 14.2.4

I've set up a pretty basic username/password auth. Page renders client side form, which calls upon client action inside which server action is awaited and based on what action return value is different UI is rendered (errors, confirmation toast, loading state, etc.).

Form code:

"use client";

import { useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { signUp, type AuthActionResponse } from "@/util/actions/auth";
import { showToast } from "../user/Toasts";
import { HideIcon, ShowIcon } from "../icons/user/preview";
import { InputFieldError } from "../user/InputFieldError";
import { SubmitFormButton } from "../user/FormButtons";

export const SignUpForm = () => {
  const [actionRes, setActionRes] = useState<AuthActionResponse>({});
  const [showPassword, setShowPassword] = useState(false);
  const [showConfirmPassword, setShowConfirmPassword] = useState(false);

  const formRef = useRef<HTMLFormElement>(null);

  function togglePasswordVisibility(field: "password" | "confirmPassword") {
    if (field === "password") {
      setShowPassword(!showPassword);
    } else {
      setShowConfirmPassword(!showConfirmPassword);
    }
  }

  async function clientSignUp(formData: FormData) {
    const res = await signUp(formData);

    if (res.status === "success-redirect" && res.message) {
      showToast(res.message, res.status, "/workouts", "View workouts");
      formRef.current?.reset();
    }

    setActionRes({ ...res });
  }

  return (
    <form ref={formRef} action={clientSignUp} className="flex flex-col gap-4">
      <input
        id="username"
        name="username"
        type="text"
        placeholder="Username"
        autoComplete="username"
        required
        className={twMerge(
          "input-field",
          "bg-white dark:bg-slate-800/50",
          actionRes.errors?.username && "ring-red-500 dark:ring-red-500",
        )}
      />
      <InputFieldError errorArr={actionRes.errors?.username} className="pl-1" />

      <input
        id="email"
        name="email"
        type="email"
        placeholder="Email"
        autoComplete="email"
        required
        className={twMerge(
          "input-field",
          "bg-white dark:bg-slate-800/50",
          actionRes.errors?.email && "ring-red-500 dark:ring-red-500",
        )}
      />
      <InputFieldError errorArr={actionRes.errors?.email} className="pl-1" />

      <div className="relative">
        <input
          id="password"
          name="password"
          type={showPassword ? "text" : "password"}
          placeholder="Password"
          autoComplete="new-password"
          required
          className={twMerge(
            "input-field",
            "w-full bg-white dark:bg-slate-800/50",
            actionRes.errors?.password && "ring-red-500 dark:ring-red-500",
          )}
        />
        <button
          type="button"
          onClick={() => togglePasswordVisibility("password")}
          className="absolute inset-y-0 right-0 px-3 py-2"
        >
          {showPassword ? (
            <HideIcon className="size-6" />
          ) : (
            <ShowIcon className="size-6" />
          )}
        </button>
      </div>
      <InputFieldError errorArr={actionRes.errors?.password} className="pl-1" />

      <div className="relative">
        <input
          id="confirmPassword"
          name="confirmPassword"
          type={showConfirmPassword ? "text" : "password"}
          placeholder="Confirm password"
          autoComplete="new-password"
          required
          className={twMerge(
            "input-field",
            "w-full bg-white dark:bg-slate-800/50",
            actionRes.errors?.confirmPassword &&
              "ring-red-500 dark:ring-red-500",
          )}
        />
        <button
          type="button"
          onClick={() => togglePasswordVisibility("confirmPassword")}
          className="absolute inset-y-0 right-0 px-3 py-2"
        >
          {showConfirmPassword ? (
            <HideIcon className="size-6" />
          ) : (
            <ShowIcon className="size-6" />
          )}
        </button>
      </div>
      <InputFieldError
        errorArr={actionRes.errors?.confirmPassword}
        className="pl-1"
      />

      {actionRes.status === "error" && (
        <InputFieldError message={actionRes.message} />
      )}

      <SubmitFormButton
        label="Sign Up"
        loading="Creating profile..."
        className="mt-6 rounded-full py-2.5"
      />
    </form>
  );
};

Action code:

export async function signUp(formData: FormData): Promise<AuthActionResponse> {
  const signUpRaw = signUpSchema.safeParse({
    username: formData.get("username"),
    email: formData.get("email"),
    password: formData.get("password"),
    confirmPassword: formData.get("confirmPassword"),
  });

  if (!signUpRaw.success) {
    return {
      status: "error",
      errors: signUpRaw.error.flatten().fieldErrors,
    };
  }

  const { username, email, password } = signUpRaw.data;

  try {
    const duplicateUsername = await db.query.users.findFirst({
      where: ilike(users.username, username.trim()),
      columns: {
        username: true,
      },
    });

    const duplicateEmail = await db.query.users.findFirst({
      where: eq(users.email, email.trim()),
      columns: {
        email: true,
      },
    });

    if (duplicateUsername?.username && duplicateEmail?.email) {
      return {
        status: "error",
        errors: {
          username: ["Username already exists."],
          email: ["This email is already taken."],
        },
      };
    } else if (duplicateUsername?.username) {
      return {
        status: "error",
        errors: {
          username: ["Username already exists."],
        },
      };
    } else if (duplicateEmail?.email) {
      return {
        status: "error",
        errors: {
          email: ["This email is already taken."],
        },
      };
    }
  } catch (error) {
    console.error(error);
    return {
      status: "error",
      message: "Database Error: Please try again.",
    };
  }

  const hashedPassword = await new Argon2id().hash(password);
  const userId = generateIdFromEntropySize(10);

  try {
    await db.insert(users).values({
      id: userId,
      username: username.trim(),
      email: email.trim(),
      hashedPassword: hashedPassword,
    });
  } catch (error) {
    console.error(error);
    return {
      status: "error",
      message: "Database Error: Profile was not created",
    };
  }

  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes,
  );

  return {
    status: "success-redirect",
    message: "Profile created successfully",
  };
}

Essentially it throws this error when trying to hash the password via Argon2id class, or any other class provided by oslo/password (Bcrypt or Scrypt).

 ⨯ Error: Failed to load native binding
    at @node-rs/argon2 (E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js:110:18)
    at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
    at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
    at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
    at eval (./src/util/actions/auth.ts:16:71)
    at (action-browser)/./src/util/actions/auth.ts (E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js:688:1)
    at Function.__webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
digest: "2107397282"
Cause: [
  Error: Cannot find module './argon2.win32-x64-msvc.node'
  Require stack:
  - E:\react\noteset\node_modules\@node-rs\argon2\index.js
  - E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js
  - E:\react\noteset\node_modules\next\dist\server\require.js
  - E:\react\noteset\node_modules\next\dist\server\load-components.js
  - E:\react\noteset\node_modules\next\dist\build\utils.js
  - E:\react\noteset\node_modules\next\dist\server\dev\hot-middleware.js
  - E:\react\noteset\node_modules\next\dist\server\dev\hot-reloader-webpack.js
  - E:\react\noteset\node_modules\next\dist\server\lib\router-utils\setup-dev-bundler.js
  - E:\react\noteset\node_modules\next\dist\server\lib\router-server.js
  - E:\react\noteset\node_modules\next\dist\server\lib\start-server.js
      at Module._resolveFilename (node:internal/modules/cjs/loader:1145:15)
      at E:\react\noteset\node_modules\next\dist\server\require-hook.js:55:36
      at Module._load (node:internal/modules/cjs/loader:986:27)
      at Module.require (node:internal/modules/cjs/loader:1233:19)
      at mod.require (E:\react\noteset\node_modules\next\dist\server\require-hook.js:65:28)
      at require (node:internal/modules/helpers:179:18)
      at requireNative (E:\react\noteset\node_modules\@node-rs\argon2\index.js:91:16)
      at Object.<anonymous> (E:\react\noteset\node_modules\@node-rs\argon2\index.js:332:17)
      at Module._compile (node:internal/modules/cjs/loader:1358:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1416:10)
      at Module.load (node:internal/modules/cjs/loader:1208:32)
      at Module._load (node:internal/modules/cjs/loader:1024:12)
      at Module.require (node:internal/modules/cjs/loader:1233:19)
      at mod.require (E:\react\noteset\node_modules\next\dist\server\require-hook.js:65:28)
      at require (node:internal/modules/helpers:179:18)
      at @node-rs/argon2 (E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js:110:18)
      at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at eval (webpack-internal:///(action-browser)/./node_modules/oslo/dist/password/argon2id.js:5:73)
      at (action-browser)/./node_modules/oslo/dist/password/argon2id.js (E:\react\noteset\.next\server\vendor-chunks\oslo.js:260:1)
      at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at eval (webpack-internal:///(action-browser)/./node_modules/oslo/dist/password/index.js:7:70)
      at (action-browser)/./node_modules/oslo/dist/password/index.js (E:\react\noteset\.next\server\vendor-chunks\oslo.js:300:1)
      at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at eval (webpack-internal:///(action-browser)/./src/util/actions/auth.ts:16:71)
      at (action-browser)/./src/util/actions/auth.ts (E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js:688:1)
      at Function.__webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
      at async endpoint (webpack-internal:///(action-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-action-entry-loader.js?actions=%5B%5B%22E%3A%5C%5Creact%5C%5Cnoteset%5C%5Csrc%5C%5Cutil%5C%5Cactions%5C%5Cauth.ts%22%2C%5B%22getAuth%22%2C%22logout%22%2C%22signUp%22%2C%22%24%24ACTION_0%22%2C%22login%22%5D%5D%5D&__client_imported__=true!:11:18)
      at async E:\react\noteset\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:418
      at async rw (E:\react\noteset\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:38:7978)
      at async r4 (E:\react\noteset\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:41:1251)
      at async doRender (E:\react\noteset\node_modules\next\dist\server\base-server.js:1438:30)
      at async cacheEntry.responseCache.get.routeKind (E:\react\noteset\node_modules\next\dist\server\base-server.js:1599:28)
      at async DevServer.renderToResponseWithComponentsImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:1507:28)
      at async DevServer.renderPageComponent (E:\react\noteset\node_modules\next\dist\server\base-server.js:1931:24)
      at async DevServer.renderToResponseImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:1969:32)
      at async DevServer.pipeImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:920:25)
      at async NextNodeServer.handleCatchallRenderRequest (E:\react\noteset\node_modules\next\dist\server\next-server.js:272:17)
      at async DevServer.handleRequestImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:816:17)
      at async E:\react\noteset\node_modules\next\dist\server\dev\next-dev-server.js:339:20
      at async Span.traceAsyncFn (E:\react\noteset\node_modules\next\dist\trace\trace.js:154:20)
      at async DevServer.handleRequest (E:\react\noteset\node_modules\next\dist\server\dev\next-dev-server.js:336:24)
      at async invokeRender (E:\react\noteset\node_modules\next\dist\server\lib\router-server.js:174:21)
      at async handleRequest (E:\react\noteset\node_modules\next\dist\server\lib\router-server.js:353:24)
      at async requestHandlerImpl (E:\react\noteset\node_modules\next\dist\server\lib\router-server.js:377:13)
      at async Server.requestListener (E:\react\noteset\node_modules\next\dist\server\lib\start-server.js:141:13) {
    code: 'MODULE_NOT_FOUND',
    requireStack: [
      'E:\\react\\noteset\\node_modules\\@node-rs\\argon2\\index.js',
      'E:\\react\\noteset\\.next\\server\\app\\(landingpage)\\sign-up\\page.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\require.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\load-components.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\build\\utils.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\dev\\hot-middleware.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\dev\\hot-reloader-webpack.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\lib\\router-utils\\setup-dev-bundler.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\lib\\router-server.js',
      'E:\\react\\noteset\\node_modules\\next\\dist\\server\\lib\\start-server.js'
    ]
  },
  Error: A dynamic link library (DLL) initialization routine failed.
  \\?\E:\react\noteset\node_modules\@node-rs\argon2-win32-x64-msvc\argon2.win32-x64-msvc.node
      at Module._extensions..node (node:internal/modules/cjs/loader:1454:18)
      at Module.load (node:internal/modules/cjs/loader:1208:32)
      at Module._load (node:internal/modules/cjs/loader:1024:12)
      at Module.require (node:internal/modules/cjs/loader:1233:19)
      at mod.require (E:\react\noteset\node_modules\next\dist\server\require-hook.js:65:28)
      at require (node:internal/modules/helpers:179:18)
      at requireNative (E:\react\noteset\node_modules\@node-rs\argon2\index.js:96:16)
      at Object.<anonymous> (E:\react\noteset\node_modules\@node-rs\argon2\index.js:332:17)
      at Module._compile (node:internal/modules/cjs/loader:1358:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1416:10)
      at Module.load (node:internal/modules/cjs/loader:1208:32)
      at Module._load (node:internal/modules/cjs/loader:1024:12)
      at Module.require (node:internal/modules/cjs/loader:1233:19)
      at mod.require (E:\react\noteset\node_modules\next\dist\server\require-hook.js:65:28)
      at require (node:internal/modules/helpers:179:18)
      at @node-rs/argon2 (E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js:110:18)
      at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at eval (webpack-internal:///(action-browser)/./node_modules/oslo/dist/password/argon2id.js:5:73)
      at (action-browser)/./node_modules/oslo/dist/password/argon2id.js (E:\react\noteset\.next\server\vendor-chunks\oslo.js:260:1)
      at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at eval (webpack-internal:///(action-browser)/./node_modules/oslo/dist/password/index.js:7:70)
      at (action-browser)/./node_modules/oslo/dist/password/index.js (E:\react\noteset\.next\server\vendor-chunks\oslo.js:300:1)
      at __webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at eval (webpack-internal:///(action-browser)/./src/util/actions/auth.ts:16:71)
      at (action-browser)/./src/util/actions/auth.ts (E:\react\noteset\.next\server\app\(landingpage)\sign-up\page.js:688:1)
      at Function.__webpack_require__ (E:\react\noteset\.next\server\webpack-runtime.js:33:42)
      at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
      at async endpoint (webpack-internal:///(action-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-action-entry-loader.js?actions=%5B%5B%22E%3A%5C%5Creact%5C%5Cnoteset%5C%5Csrc%5C%5Cutil%5C%5Cactions%5C%5Cauth.ts%22%2C%5B%22getAuth%22%2C%22logout%22%2C%22signUp%22%2C%22%24%24ACTION_0%22%2C%22login%22%5D%5D%5D&__client_imported__=true!:11:18)
      at async E:\react\noteset\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:418
      at async rw (E:\react\noteset\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:38:7978)
      at async r4 (E:\react\noteset\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:41:1251)
      at async doRender (E:\react\noteset\node_modules\next\dist\server\base-server.js:1438:30)
      at async cacheEntry.responseCache.get.routeKind (E:\react\noteset\node_modules\next\dist\server\base-server.js:1599:28)
      at async DevServer.renderToResponseWithComponentsImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:1507:28)
      at async DevServer.renderPageComponent (E:\react\noteset\node_modules\next\dist\server\base-server.js:1931:24)
      at async DevServer.renderToResponseImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:1969:32)
      at async DevServer.pipeImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:920:25)
      at async NextNodeServer.handleCatchallRenderRequest (E:\react\noteset\node_modules\next\dist\server\next-server.js:272:17)
      at async DevServer.handleRequestImpl (E:\react\noteset\node_modules\next\dist\server\base-server.js:816:17)
      at async E:\react\noteset\node_modules\next\dist\server\dev\next-dev-server.js:339:20
      at async Span.traceAsyncFn (E:\react\noteset\node_modules\next\dist\trace\trace.js:154:20)
      at async DevServer.handleRequest (E:\react\noteset\node_modules\next\dist\server\dev\next-dev-server.js:336:24)
      at async invokeRender (E:\react\noteset\node_modules\next\dist\server\lib\router-server.js:174:21)
      at async handleRequest (E:\react\noteset\node_modules\next\dist\server\lib\router-server.js:353:24)
      at async requestHandlerImpl (E:\react\noteset\node_modules\next\dist\server\lib\router-server.js:377:13)
      at async Server.requestListener (E:\react\noteset\node_modules\next\dist\server\lib\start-server.js:141:13) {
    code: 'ERR_DLOPEN_FAILED'
  }
]

I have done few things to try to fix this like adding "@node-rs/argon2" as separate dependency and modifying next.config.js to look like this

const nextConfig = {
  webpack: (config) => {
    config.externals.push("@node-rs/argon2", "@node-rs/bcrypt");
    return config;
  },
};

but none of these solutions helped. Switching to Bcrypt as standalone dependency fixes the issue, but I would like to use Argon and would like to follow recommended Lucia setup.

Looking forward to your response and if you need anything else feel free to ask!

mooxl commented 1 month ago

Using "oslo": "^1.2.1" the same issue occurs with a standard Node.js server bundled using tsup to generate ESM.

tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  clean: true,
  format: "esm",
  noExternal: [
    "@shift-desk/constants",
    "@shift-desk/database",
    "@shift-desk/importer",
    "@shift-desk/types",
  ],
  esbuildOptions: (options) => {
    options.external?.push("@node-rs/argon2", "@node-rs/bcrypt");
    return options;
  },
  tsconfig: "tsconfig.json",
});

output

CLI Building entry: src/index.ts
CLI Using tsconfig: tsconfig.json
CLI tsup v8.2.3
CLI Using tsup config: /path/apps/server/tsup.config.ts
CLI Target: es2022
CLI Cleaning output folder
ESM Build start
ESM dist/index.js 211.87 KB
ESM ⚡️ Build success in 39ms
cache bypass, force executing bdfbbfffe9f3ce47
> @shift-desk/server@0.0.0 preview /path/apps/server
> node ./dist/index.js

file:///path/apps/server/dist/index.js:11
  throw Error('Dynamic require of "' + x + '" is not supported');
        ^

Error: Dynamic require of "assert" is not supported
    at file:///path/apps/server/dist/index.js:11:9
    at ../../node_modules/.pnpm/argon2@0.40.3/node_modules/argon2/argon2.cjs (file:///path/apps/server/dist/index.js:380:18)
    at __require2 (file:///path/apps/server/dist/index.js:14:50)
    at file:///path/apps/server/dist/index.js:5727:22
    at ModuleJob.run (node:internal/modules/esm/module_job:222:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:316:24)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:123:5)

Node.js v20.15.0
 ELIFECYCLE  Command failed with exit code 1.
ERROR: command finished with error: command (/Users/max.schmidt/progg/shiftdesk/apps/server) /Users/max.schmidt/.nvm/versions/node/v20.15.0/bin/pnpm run preview exited (1)
isaacfink commented 1 month ago

I am running into a similar issue with both Argon2 and Bcrypt when bundling with rollup (on a sveltekit project) here is the full error

error during build:
[commonjs--resolver] ../../node_modules/.pnpm/@node-rs+bcrypt-darwin-arm64@1.9.0/node_modules/@node-rs/bcrypt-darwin-arm64/bcrypt.darwin-arm64.node (1:0): Unexpected character '�' (Note that you need plugins to import files that are not JavaScript)
file: /Users/isaac/Desktop/projects/project/node_modules/.pnpm/@node-rs+bcrypt@1.9.0/node_modules/@node-rs/bcrypt/index.js:1:0

1: ����
       ���__TEXT�__text...
   ^
2: �H__LINKEDIT@
�/Users/runner/work/node-rs/node-rs/target/aarch6...

It seems like an issue with the rust solutions, we probably need a pure js solution (even though it's slow) in order to get around bundlers