├── .dockerignore ├── .env.example ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── dist └── .npmignore ├── docker-compose.yml ├── eslint.config.mjs ├── examples ├── app.ts ├── basic │ ├── auth.ts │ └── server.ts ├── external-api │ ├── auth.ts │ ├── client.ts │ └── server.ts ├── hashing │ ├── auth.ts │ └── server.ts └── ldap-auth │ ├── auth.ts │ └── server.ts ├── index.ts ├── package-lock.json ├── package.json ├── src ├── credentials │ ├── client.ts │ ├── error-codes.ts │ ├── index.ts │ └── schema.ts └── utils │ └── zod.ts ├── test ├── examples │ ├── basic.node.spec.ts │ ├── external-api.node.spec.ts │ ├── hashing.node.spec.ts │ └── ldap.node.spec.ts ├── global.d.ts ├── plugin.ts ├── plugin │ ├── behaviour-email-passw.node.spec.ts │ ├── client.node.spec.ts │ ├── config.node.spec.ts │ ├── email-verification.node.spec.ts │ ├── link-account.node.spec.ts │ ├── no-signup.node.spec.ts │ └── zod3.node.spec.ts ├── setupEach.ts └── test-helpers.ts ├── tsconfig.json └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | code 2 | .vscode 3 | node_modules 4 | dist 5 | coverage 6 | .git 7 | .dockerignore 8 | npm-debug.log 9 | Dockerfile 10 | Dockerfile.* 11 | docker-compose.yml 12 | README.md -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | 3 | BETTER_AUTH_SECRET="insiraoseusecretaqui" 4 | BETTER_AUTH_URL="http://localhost:3000" 5 | 6 | ## Conexão com o banco de dados 7 | # DB_URL_AUTH="mongodb://127.0.0.1:27017/better-auth" 8 | 9 | ## Configuração LDAP () 10 | # LDAP_URL="ldap://localhost:10389" 11 | # LDAP_BIND_DN="cn=admin,dc=planetexpress,dc=com" 12 | # LDAP_PASSW="GoodNewsEveryone" 13 | # LDAP_BASE_DN="dc=planetexpress,dc=com" 14 | # LDAP_SEARCH_ATTR="uid" 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI CD Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | # https://github.com/nodejs/Release#release-schedule 17 | # https://github.com/nvm-sh/nvm 18 | # 20.x 22.x 24.x 19 | node: [ "lts/iron", "lts/*", "latest" ] 20 | name: Test Node.js ${{ matrix.node }} 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node }} 26 | 27 | - name: Set up environment 28 | run: | 29 | mv ./.env.example ./.env 30 | node -v 31 | 32 | - name: Install and run tests 33 | run: | 34 | npm ci 35 | npm run test 36 | # https://docs.github.com/en/actions/how-tos/use-cases-and-examples/publishing-packages/publishing-nodejs-packages 37 | build: 38 | runs-on: ubuntu-latest 39 | permissions: 40 | contents: read 41 | id-token: write 42 | steps: 43 | - uses: actions/checkout@v4 44 | # Setup .npmrc file to publish to npm 45 | - uses: actions/setup-node@v4 46 | with: 47 | node-version: 'lts/*' 48 | registry-url: 'https://registry.npmjs.org' 49 | - run: npm ci 50 | - run: npm run build 51 | - run: npm publish --provenance 52 | env: 53 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/public 2 | 3 | # Typescript build 4 | dist/* 5 | !dist/.npmignore 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | .cache 111 | 112 | # vitepress build output 113 | **/.vitepress/dist 114 | 115 | # vitepress cache directory 116 | **/.vitepress/cache 117 | 118 | # Docusaurus cache and generated files 119 | .docusaurus 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | # Stores VSCode versions used for testing VSCode extensions 134 | .vscode-test 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | WORKDIR /node-app 4 | 5 | ARG NODE_ENV=production 6 | ENV NODE_ENV=${NODE_ENV} 7 | 8 | ARG PORT=3000 9 | ENV PORT=${PORT} 10 | EXPOSE ${PORT} 11 | 12 | # Primeiro instala as dependências 13 | COPY package.json package-lock.json ./ 14 | RUN npm ci 15 | 16 | # Depois copia o projeto (Isto torna mais rápido o build devido ao cache) 17 | COPY . . 18 | 19 | RUN npm run build 20 | 21 | # Ponto de partida 22 | ENTRYPOINT ["npm", "start"] 23 | 24 | # Instruções no README.md Para executar via docker manualmente -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Erick Leonardo Weil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better Auth Credentials Plugin 2 | 3 | [![npm version](https://badge.fury.io/js/better-auth-credentials-plugin.svg)](https://badge.fury.io/js/better-auth-credentials-plugin) 4 | 5 | Generic credentials authentication plugin for Better Auth 6 | 7 | The plugin itself can be used to authenticate to anything, as are you that handle the logic that verify user input credentials in the callback, and just need to return user data that will be used to create/update the user in the database. 8 | 9 | (Early version, experimental, the behaviour WILL CHANGE) 10 | 11 | ## Features 12 | - Full control over the authentication process 13 | - Auto sign-up (optional) and management of Account linking and session creation 14 | - Similar in behaviour to the default email & password flow, but YOU handle the verification of the credentials and allow automatically sign-up 15 | - Route customization, you can choose the route path and the body parameters (using zod schema that doubles as validation and OpenAPI documentation) 16 | - Supports custom callbacks for sign-in and sign-up events 17 | 18 | Examples (All are built using express + MongoDB): 19 | - examples/basic - Basic usage example with a fake user store, showcasing the credentials callback functionality and how to handle user data 20 | - examples/ldap-auth - Uses this plugin to perform LDAP authentication, showing how easy is to use it 21 | 22 | Considerations: 23 | - You need to return a `email` field after the authentication, this is used to create/update the user in the database, and also to link the account with the session (email field should be unique). 24 | - It's not intended to use this to re-implement password login, but to be used when you need to integrate with an external system that uses credentials for authentication, like LDAP, or any other system that you can verify the credentials and get user data. If you try to mimic password login by hashing and storing the password, aditional database round-trips will be needed as this plugin will search the user again after you alread did (just use the email & password flow or username plugin don't do this). 25 | 26 | **Installation** 27 | https://www.npmjs.com/package/better-auth-credentials-plugin 28 | ```bash 29 | npm install better-auth-credentials-plugin 30 | ``` 31 | 32 | ## API Details 33 | 34 | ### Configuration of the plugin 35 | 36 | To use this plugin, you need to install it and configure it in your Better Auth application. The plugin provides a way to authenticate users using credentials (like username and password) that can be customized to fit your needs. 37 | 38 | Hello world usage example (just to show how to use the plugin): 39 | `auth.ts` 40 | ```javascript 41 | import { betterAuth } from "better-auth"; 42 | import { credentials } from "better-auth-credentials-plugin"; 43 | 44 | // Server side: 45 | export const auth = betterAuth({ 46 | /** ... other configs ... */ 47 | emailAndPassword: { 48 | // Disable email and password authentication 49 | enabled: false, 50 | }, 51 | plugins: [ 52 | credentials({ 53 | autoSignUp: true, 54 | async callback(ctx, parsed) { 55 | return {}; 56 | }, 57 | }) 58 | ], 59 | }); 60 | 61 | // Client side: 62 | import { User } from "better-auth"; 63 | import { createAuthClient } from "better-auth/client"; 64 | import { credentialsClient, defaultCredentialsSchema } from "better-auth-credentials-plugin"; 65 | 66 | export const authClient = createAuthClient({ 67 | plugins: [ 68 | credentialsClient(), 69 | ], 70 | }); 71 | ``` 72 | 73 | Doing as above would allow any user sign in with any password, and create new users automatically if they don't exist. 74 | 75 | The full set of options for the plugin is as follows: 76 | 77 | | Attribute | Description | 78 | |-----------------------------|----------------------------------------------------------------------------------| 79 | | `callback` * | This callback is the only required option, here you handle the login logic and return the user data to create a new user or update existing ones | 80 | | `inputSchema` | Zod schema that defined the body contents of the sign-in route, you can put any schema you like, but if it doesn't have an `email` field, you then need to return the email to use in the callback. Defaults to the same as User & Password flow `{email: string, password: string, rememberMe?: boolean}` | 81 | | `autoSignUp` | If true will create new Users and Accounts if the don't exist | 82 | | `linkAccountIfExisting` | If true, will link the Account on existing users created with another login method (Only have effect with autoSignUp true) | 83 | | `providerId` | Id of the Account provider defaults to `credential` | 84 | | `path` | Path of the route endpoint, defaults to `/sign-in/credentials` | 85 | | `UserType` | If you have aditional fields in the User type and want correct typescript types in the callbacks, you can set here it's type, example: `{} as User & {lastLogin: Date}` | 86 | 87 | If the callback throws an error or returns a falsy value, auth will fail with generic 401 Invalid Credentials error. 88 | 89 | You then must return an object with the following shape: 90 | | Attribute | Description | 91 | |-----------------------------|----------------------------------------------------------------------------------| 92 | | `...userData` | User data that will be used to create or update the user in the database, this must contain an `email` field if the inputSchema doesn't have it | 93 | | `onSignIn` | Callback that will be called after the user is sucesfully signed in. It receives the user data returned above, User and Account from database as parameters, and you should return the mutated user data to update (The account linking happens after this callback, so it can be null) | 94 | | `onSignUp` | Callback that will be called after the user is sucesfully signed up (only if autoSignUp is true). It receives the user data returned above, and you should return the mutated user data with the fields a new user should have | 95 | | `onLinkAccount` | Callback that will be called when a Account is linked to the user. Can happen in a fresh new user sign up or the first time a existing user signs in with this credentials provider. It receives the User from database as parameter, and you should return additional fields to put in the Account being created. You shouldn't throw errors on this callback, because when it runs the user was already created in the database | 96 | 97 | > All those callbacks can be async if you want. 98 | 99 | - If the onSignIn returns a falsy value or throws an error, auth will fail with generic 401 Invalid Credentials error, you can return an empty object to skip updating the user data in the database. 100 | - If the onSignUp returns a object without email field, falsy value or throws an error, auth will fail with generic 401 Invalid Credentials error. 101 | - OnLinkAccount shouldn't throw errors nor return a falsy value, in the moment this callback is called the user was already created, so you'll leave a user without an account linked to it, which could cause issues. 102 | 103 | > If the error you throw is a instance of APIError from `better-call` package, the error returned will be the one you threw instead of the generic 401 Invalid Credentials error, so this way you can return a more specific error code and message to the user if needed. 104 | 105 | ## Usage examples 106 | 107 | ### Basic: Accept only equal email and password 108 | Example using the plugin to authenticate users with a simple username and password, where the credentials must be the same as the password. This is just for demonstration purposes, 109 | 110 | [examples/basic](examples/basic) 111 | ```javascript 112 | credentials({ 113 | autoSignUp: true, 114 | // Credentials login callback, this is called when the user submits the form 115 | async callback(ctx, parsed) { 116 | // Just for demonstration purposes, half of the time we will fail the authentication 117 | if (parsed.email !== parsed.password) { 118 | throw new Error("Authentication failed, please try again."); 119 | } 120 | 121 | return { 122 | // Called if this is a existing user sign-in 123 | onSignIn(userData, user, account) { 124 | console.log("Existing User signed in:", user); 125 | 126 | return userData; 127 | }, 128 | 129 | // Called if this is a new user sign-up (only used if autoSignUp is true) 130 | onSignUp(userData) { 131 | console.log("New User signed up:", userData.email); 132 | 133 | return { 134 | ...userData, 135 | name: parsed.email.split("@")[0] 136 | }; 137 | } 138 | }; 139 | }, 140 | }) 141 | ``` 142 | 143 | ### Login on external API 144 | Example using the plugin to authenticate users against an external API, when you want to use the plugin to authenticate users against an external system that uses credentials for authentication, like a custom API or service. For this demonstration, the API has predefined users and returns user data after successful authentication. 145 | 146 | Server side: 147 | [examples/external-api/auth.ts](examples/external-api/auth.ts) 148 | ```javascript 149 | 150 | export const myCustomSchema = z.object({ 151 | username: z.string().min(1), 152 | password: z.string().min(1), 153 | }); 154 | 155 | export const auth = betterAuth({ 156 | plugins: [ 157 | credentials({ 158 | autoSignUp: true, 159 | path: "/sign-in/external", 160 | inputSchema: myCustomSchema, 161 | // Credentials login callback, this is called when the user submits the form 162 | async callback(ctx, parsed) { 163 | // Simulate an external API call to authenticate the user 164 | const { username, password } = parsed; 165 | const response = await fetch(`http://localhost:${process.env.PORT || 3000}/example/login`, { 166 | method: "POST", 167 | headers: { 168 | "Content-Type": "application/json", 169 | }, 170 | body: JSON.stringify({ username, password }), 171 | }); 172 | 173 | if (!response.ok) { 174 | throw new Error("Error authenticating:"+ ` ${response.status} ${response.statusText}`); 175 | } 176 | 177 | const apiUser = await response.json(); 178 | 179 | return { 180 | // Must return email, because inputSchema doesn't have it 181 | email: apiUser.email, 182 | 183 | // Other user data to update 184 | name: apiUser.name, 185 | username: apiUser.username, 186 | }; 187 | }, 188 | }), 189 | ], 190 | }); 191 | ``` 192 | 193 | When you provide custom path and inputSchema, you must pass the type parameters to the `credentialsClient` on the client side, so it can infer the correct types for the user data and input schema. 194 | 195 | Client side: 196 | [examples/external-api/client.ts](examples/external-api/client.ts) 197 | ```javascript 198 | export const authClient = createAuthClient({ 199 | // The base URL of your Better Auth API 200 | baseURL: `http://localhost:${port}`, 201 | plugins: [ 202 | // Initialize the client plugin with the correct generic types parameters: 203 | // 0: User -> The type of the user returned by the API 204 | // 1: "/sign-in/external" -> The path for the credentials sign-in endpoint 205 | // 2: typeof myCustomSchema -> The input schema for the credentials sign-in 206 | credentialsClient(), 207 | 208 | // https://www.better-auth.com/docs/concepts/typescript#inferring-additional-fields-on-client 209 | // This will infer the additional fields defined in the auth schema 210 | // and make them available on the client (e.g., `username`). 211 | inferAdditionalFields(), 212 | ], 213 | }); 214 | ``` 215 | 216 | ### LDAP Authentication Example 217 | Example using the plugin to authenticate users against an LDAP server, showcasing how to use the plugin with an external authentication system. 218 | 219 | > Uses https://github.com/shaozi/ldap-authentication for LDAP authentication 220 | 221 | [examples/ldap-auth](examples/ldap-auth) 222 | ```javascript 223 | credentials({ 224 | // User type to use, this will be used to type the user in the callback 225 | // This way the zod schema will infer correctly, otherwise you would have to pass both generic types explicitly 226 | UserType: {} as User & { 227 | ldap_dn: string, 228 | description: string, 229 | groups: string[] 230 | }, 231 | // Sucessful authenticated users will have a 'ldap' Account linked to them, no matter if they previously exists or not 232 | autoSignUp: true, 233 | linkAccountIfExisting: true, 234 | providerId: "ldap", 235 | inputSchema: z.object({ 236 | credential: z.string().min(1), 237 | password: z.string().min(1) 238 | }), 239 | // Credentials login callback, this is called when the user submits the form 240 | async callback(ctx, parsed) { 241 | // Login via LDAP and return user data 242 | const secure = process.env.LDAP_URL!.startsWith("ldaps://"); 243 | const ldapResult = await authenticate({ 244 | // LDAP client connection options 245 | ldapOpts: { 246 | url: process.env.LDAP_URL!, 247 | connectTimeout: 5000, 248 | strictDN: true, 249 | ...(secure ? {tlsOptions: { minVersion: "TLSv1.2" }} : {}) 250 | }, 251 | adminDn: process.env.LDAP_BIND_DN, 252 | adminPassword: process.env.LDAP_PASSW, 253 | userSearchBase: process.env.LDAP_BASE_DN, 254 | usernameAttribute: process.env.LDAP_SEARCH_ATTR, 255 | // https://github.com/shaozi/ldap-authentication/issues/82 256 | //attributes: ['jpegPhoto;binary', 'displayName', 'uid', 'mail', 'cn'], 257 | explicitBufferAttributes: ["jpegPhoto"], 258 | 259 | username: parsed.credential, 260 | userPassword: parsed.password, 261 | }); 262 | const uid = ldapResult[process.env.LDAP_SEARCH_ATTR!]; 263 | 264 | return { 265 | // Required to return email to identify the user, as the inputSchema does not have it 266 | email: (Array.isArray(ldapResult.mail) ? ldapResult.mail[0] : ldapResult.mail) || `${uid}@local`, 267 | 268 | // Atributes that will be saved in the user, regardless if is sign-in or sign-up 269 | ldap_dn: ldapResult.dn, 270 | name: ldapResult.displayName || uid, 271 | description: ldapResult.description || "", 272 | groups: ldapResult.objectClass && Array.isArray(ldapResult.objectClass) ? ldapResult.objectClass : [], 273 | 274 | // Callback that is called after sucessful sign-up (New user) 275 | async onSignUp(userData) { 276 | // Only on sign-up we save the image to disk and save the url in the user data 277 | if(ldapResult.jpegPhoto) { 278 | userData.image = await saveImageToDisk(ldapResult.uid, ldapResult.jpegPhoto); 279 | } 280 | 281 | return userData; 282 | }, 283 | }; 284 | }, 285 | }) 286 | ``` 287 | 288 | ## Building and running the example 289 | 290 | Requirements: 291 | - Node.js (v20 or later) 292 | - Docker 293 | 294 | 1. Clone the repository: 295 | ```bash 296 | git clone https://github.com/erickweil/better-auth-credentials-plugin.git 297 | cd better-auth-credentials-plugin 298 | ``` 299 | 300 | 2. Install dependencies and build the project: 301 | ```bash 302 | npm install 303 | npm run build 304 | ``` 305 | 306 | 3. Start the MongoDB server and the test LDAP server using Docker: 307 | ```bash 308 | docker compose up -d 309 | ``` 310 | 311 | 4. Run the example: 312 | ```bash 313 | cp .env.example .env 314 | npm run example:ldap 315 | ``` 316 | 317 | 5. Open your browser and go to `http://localhost:3000`. You should see the better-auth OpenAPI plugin docs 318 | 319 | - Now you can login with the LDAP credentials, go to Credentials -> `/sign-in/credentials` and use the following credentials (username & password must be those values): 320 | ```json 321 | { 322 | "credential": "fry", 323 | "password": "fry" 324 | } 325 | ``` 326 | > You can use any value from the default values: https://github.com/rroemhild/docker-test-openldap 327 | 328 | Using ldap sign-up should be done automatically after the first sucessful sign-in via LDAP, just like social sign-in, (unless you don't have it enabled it in the configuration) 329 | 330 | ## Running the tests 331 | 332 | ```bash 333 | docker compose up -d 334 | npm run test 335 | ``` 336 | 337 | ## License 338 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 339 | 340 | ## Contributing 341 | 342 | Contributions are welcome! But please note that this is an early version and not yet ready for anything. If you have any ideas or improvements, feel free to open an issue or submit a pull request. 343 | 344 | ## Acknowledgements 345 | 346 | This project is inspired by the need for a simple and effective way to integrate LDAP authentication into Better Auth. Special thanks to the Better Auth team for their work on the core library. 347 | 348 | Also this project would not be possible if not for shaozi/ldap-authentication package which was used for the LDAP authentication 349 | - https://github.com/shaozi/ldap-authentication -------------------------------------------------------------------------------- /dist/.npmignore: -------------------------------------------------------------------------------- 1 | examples/ 2 | test/ -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | teste-mongo: 3 | restart: unless-stopped 4 | container_name: teste-mongo 5 | image: mongo:8 6 | ports: 7 | - 27017:27017 8 | volumes: 9 | - vol-db:/data/db 10 | 11 | # Este container é utilizado para testes, não é necessário para o funcionamento da aplicação, para rodar os testes 12 | # 13 | # https://github.com/rroemhild/docker-test-openldap 14 | # OpenLDAP Docker Image for testing 15 | # This Docker image provides an OpenLDAP Server for testing LDAP applications, i.e. unit tests. 16 | # The server is initialized with the example domain planetexpress.com with data from the Futurama Wiki. 17 | teste-openldap: 18 | container_name: teste-openldap 19 | restart: unless-stopped 20 | image: ghcr.io/rroemhild/docker-test-openldap:master 21 | ports: 22 | - "10389:10389" 23 | - "10636:10636" 24 | environment: 25 | - LDAP_BINDDN=cn=admin,dc=planetexpress,dc=com 26 | - LDAP_BASE_SEARCH=ou=people,dc=planetexpress,dc=com 27 | 28 | 29 | volumes: 30 | vol-db: -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; 3 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 4 | import globals from "globals"; 5 | import tsParser from "@typescript-eslint/parser"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | import js from "@eslint/js"; 9 | import { FlatCompat } from "@eslint/eslintrc"; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all 17 | }); 18 | 19 | export default defineConfig( 20 | [globalIgnores(["**/node_modules", "**/coverage", "**/dist", "**/code"]), { 21 | extends: fixupConfigRules(compat.extends( 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/eslint-recommended", 24 | "plugin:@typescript-eslint/recommended", 25 | "plugin:import/recommended", 26 | "plugin:import/typescript", 27 | )), 28 | 29 | plugins: { 30 | "@typescript-eslint": fixupPluginRules(typescriptEslint), 31 | }, 32 | 33 | languageOptions: { 34 | globals: { 35 | ...globals.node, 36 | }, 37 | 38 | parser: tsParser, 39 | ecmaVersion: "latest", 40 | sourceType: "module", 41 | }, 42 | 43 | settings: { 44 | "import/resolver": { 45 | node: { 46 | extensions: [".js", ".jsx", ".ts", ".tsx"], 47 | }, 48 | }, 49 | }, 50 | 51 | rules: { 52 | quotes: ["error", "double"], 53 | semi: ["error", "always"], 54 | "no-unused-vars": "off", 55 | "@typescript-eslint/no-unused-vars": "off", 56 | "@typescript-eslint/no-explicit-any": "off", 57 | "prefer-const": "off", 58 | "no-useless-escape": "off", 59 | "no-constant-condition": "off", 60 | "no-var": "error", 61 | "no-implicit-globals": "error", 62 | "no-use-before-define": "off", 63 | "no-duplicate-imports": "error", 64 | "no-invalid-this": "error", 65 | "no-shadow": "error", 66 | "import/no-absolute-path": "error", 67 | "import/no-self-import": "error", 68 | 69 | "import/extensions": ["error", "ignorePackages", { 70 | js: "always", 71 | ts: "never", 72 | }], 73 | 74 | "import/no-unresolved": "off", 75 | }, 76 | }], 77 | ); -------------------------------------------------------------------------------- /examples/app.ts: -------------------------------------------------------------------------------- 1 | import express, { ErrorRequestHandler } from "express"; 2 | import { fromNodeHeaders, toNodeHandler } from "better-auth/node"; 3 | export function getApp(auth: any, callback?: (app: express.Express) => void) { 4 | const app = express(); 5 | 6 | // https://www.better-auth.com/docs/installation 7 | // Deve ser antes do middleware de parsing do body 8 | app.all("/api/auth/{*any}", toNodeHandler(auth)); 9 | 10 | app.get("/", (req, res) => { 11 | res.status(200).redirect("api/auth/reference"); // redirecionando para documentação 12 | }); 13 | 14 | // Serve static files from the public directory 15 | app.use("/public", express.static("public")); 16 | 17 | app.use(express.json()); 18 | app.use(express.urlencoded({ extended: false })); 19 | 20 | app.get("/me", async (req, res) => { 21 | // https://github.com/Bekacru/t3-app-better-auth/blob/main/src/server/auth.ts 22 | const session = await auth.api.getSession({ 23 | headers: fromNodeHeaders(req.headers) 24 | }); 25 | if (!session) { 26 | res.status(401).json({ message: "Usuário não autenticado" }); 27 | return; 28 | } 29 | 30 | res.status(200).json({ 31 | user: session?.user || null, 32 | session: session?.session || null 33 | }); 34 | }); 35 | 36 | if (callback) { 37 | callback(app); 38 | } 39 | 40 | app.use((req, res, next) => { 41 | res.status(404).json({ message: "Rota não encontrada" }); 42 | }); 43 | 44 | // Middleware de tratamento de erros, sempre por último 45 | const errorHandler: ErrorRequestHandler = (err, req, res, next) => { 46 | console.error("Erro no servidor:", err); 47 | 48 | res.status(err.status || 500).json({ message: err.message || "Erro interno do servidor" }); 49 | }; 50 | app.use(errorHandler); 51 | 52 | if(process.env.NODE_ENV !== "test") { 53 | const port = process.env.PORT || 3000; 54 | app.listen(port, () => { 55 | console.log("Servidor está rodando na porta %d", port); 56 | }); 57 | } 58 | 59 | return app; 60 | } -------------------------------------------------------------------------------- /examples/basic/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth, User } from "better-auth"; 2 | import { mongodbAdapter } from "better-auth/adapters/mongodb"; 3 | import { openAPI } from "better-auth/plugins"; 4 | import { MongoClient } from "mongodb"; 5 | import { credentials } from "../../src/credentials/index.js"; 6 | 7 | // https://www.better-auth.com/docs/adapters/mongo 8 | // For MongoDB, we don't need to generate or migrate the schema. 9 | const client = new MongoClient(process.env.DB_URL_AUTH!); 10 | const db = client.db(); 11 | 12 | export const auth = betterAuth({ 13 | database: mongodbAdapter(db), 14 | emailAndPassword: { 15 | enabled: true, 16 | }, 17 | plugins: [ 18 | openAPI(), 19 | credentials({ 20 | autoSignUp: true, 21 | // Credentials login callback, this is called when the user submits the form 22 | async callback(ctx, parsed) { 23 | if (parsed.email !== parsed.password) { 24 | throw new Error("Authentication failed, please try again."); 25 | } 26 | 27 | return { 28 | // Called if this is a existing user sign-in 29 | onSignIn(userData, user, account) { 30 | console.log("Existing User signed in:", user); 31 | 32 | return userData; 33 | }, 34 | 35 | // Called if this is a new user sign-up (only used if autoSignUp is true) 36 | onSignUp(userData) { 37 | console.log("New User signed up:", userData.email); 38 | 39 | return { 40 | ...userData, 41 | name: parsed.email.split("@")[0] 42 | }; 43 | } 44 | }; 45 | }, 46 | }) 47 | ], 48 | }); -------------------------------------------------------------------------------- /examples/basic/server.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { auth } from "./auth.js"; 4 | import { getApp } from "../app.js"; 5 | 6 | const app = getApp(auth); 7 | export default app; -------------------------------------------------------------------------------- /examples/external-api/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth, User } from "better-auth"; 2 | import { mongodbAdapter } from "better-auth/adapters/mongodb"; 3 | import { bearer, openAPI } from "better-auth/plugins"; 4 | import { MongoClient } from "mongodb"; 5 | import { credentials } from "../../src/credentials/index.js"; 6 | import * as z from "zod"; 7 | 8 | // https://www.better-auth.com/docs/adapters/mongo 9 | // For MongoDB, we don't need to generate or migrate the schema. 10 | const client = new MongoClient(process.env.DB_URL_AUTH!); 11 | const db = client.db(); 12 | 13 | export const myCustomSchema = z.object({ 14 | username: z.string().min(1), 15 | password: z.string().min(1), 16 | }); 17 | 18 | export const auth = betterAuth({ 19 | database: mongodbAdapter(db), 20 | emailAndPassword: { 21 | // Disable email and password authentication 22 | // Users will both sign-in and sign-up via Credentials plugin 23 | enabled: false, 24 | }, 25 | user: { 26 | additionalFields: { 27 | // Add additional fields to the user model 28 | username: { 29 | type: "string", 30 | returned: true, 31 | required: false, 32 | }, 33 | } 34 | }, 35 | plugins: [ 36 | openAPI(), 37 | bearer(), 38 | credentials({ 39 | autoSignUp: true, 40 | providerId: "external-api", 41 | path: "/sign-in/external", 42 | inputSchema: myCustomSchema, 43 | // Credentials login callback, this is called when the user submits the form 44 | async callback(ctx, parsed) { 45 | // Simulate an external API call to authenticate the user 46 | const { username, password } = parsed; 47 | const response = await fetch(`http://localhost:${process.env.PORT || 3000}/example/login`, { 48 | method: "POST", 49 | headers: { 50 | "Content-Type": "application/json", 51 | }, 52 | body: JSON.stringify({ username, password }), 53 | }); 54 | 55 | if (!response.ok) { 56 | throw new Error("Error authenticating:"+ ` ${response.status} ${response.statusText}`); 57 | } 58 | 59 | const apiUser = await response.json(); 60 | 61 | return { 62 | // Must return email, because inputSchema doesn't have it 63 | email: apiUser.email, 64 | 65 | // Other user data to update 66 | name: apiUser.name, 67 | username: apiUser.username, 68 | }; 69 | }, 70 | }) 71 | ], 72 | }); -------------------------------------------------------------------------------- /examples/external-api/client.ts: -------------------------------------------------------------------------------- 1 | import { User } from "better-auth"; 2 | import { createAuthClient } from "better-auth/client"; 3 | import * as z from "zod"; 4 | import { credentialsClient } from "../../src/credentials/client.js"; 5 | import { inferAdditionalFields } from "better-auth/client/plugins"; 6 | import { auth, myCustomSchema } from "./auth.js"; 7 | 8 | interface MyUser extends User { 9 | username?: string; 10 | } 11 | 12 | const port = process.env.PORT || 3000; 13 | 14 | // Initialize the Better Auth client 15 | export const authClient = createAuthClient({ 16 | // The base URL of your Better Auth API 17 | baseURL: `http://localhost:${port}`, 18 | plugins: [ 19 | // Initialize the client plugin with the correct generic types parameters: 20 | // 0: User -> The type of the user returned by the API 21 | // 1: "/sign-in/credentials" -> The path for the credentials sign-in endpoint 22 | // 2: typeof myCustomSchema -> The input schema for the credentials sign-in 23 | credentialsClient(), 24 | 25 | // https://www.better-auth.com/docs/concepts/typescript#inferring-additional-fields-on-client 26 | // This will infer the additional fields defined in the auth schema 27 | // and make them available on the client (e.g., `username`). 28 | inferAdditionalFields(), 29 | ], 30 | }); 31 | 32 | /* 33 | // Example call to the login function 34 | // Look how the client can use both the custom path and schema, as the types are inferred correctly 35 | const { data, error } = await authClient.signIn.external({ 36 | username: "external1", 37 | password: "password1" 38 | }); 39 | */ -------------------------------------------------------------------------------- /examples/external-api/server.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { auth } from "./auth.js"; 4 | import { getApp } from "../app.js"; 5 | 6 | const users = new Array(100).fill({}).map((_, index) => ({ 7 | id: index + 1, 8 | name: `User ${index + 1}`, 9 | username: `external${index + 1}`, 10 | email: `external${index + 1}@example.com`, 11 | password: `password${index + 1}`, 12 | })); 13 | 14 | const app = getApp(auth, (_app) => { 15 | _app.post("/example/login", async (req, res, next) => { 16 | const { username, password } = req.body; 17 | 18 | // Simula uma autenticação simples 19 | const foundUser = users.find(user => user.username === username && user.password === password); 20 | 21 | if (!foundUser) { 22 | res.status(401).json({ message: "Usuário ou senha inválidos" }); 23 | return; 24 | } 25 | 26 | res.status(200).json({ 27 | ...foundUser, 28 | password: undefined, 29 | }); 30 | }); 31 | }); 32 | 33 | export default app; -------------------------------------------------------------------------------- /examples/hashing/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth, User } from "better-auth"; 2 | import { APIError, EndpointContext } from "better-call"; 3 | import { mongodbAdapter } from "better-auth/adapters/mongodb"; 4 | import { openAPI } from "better-auth/plugins"; 5 | import { MongoClient } from "mongodb"; 6 | import * as argon2 from "argon2"; 7 | import { credentials } from "../../src/credentials/index.js"; 8 | 9 | // https://www.better-auth.com/docs/adapters/mongo 10 | // For MongoDB, we don't need to generate or migrate the schema. 11 | const client = new MongoClient(process.env.DB_URL_AUTH!); 12 | const db = client.db(); 13 | 14 | export const auth = betterAuth({ 15 | database: mongodbAdapter(db), 16 | emailAndPassword: { 17 | enabled: true, 18 | }, 19 | plugins: [ 20 | openAPI(), 21 | credentials({ 22 | autoSignUp: true, 23 | linkAccountIfExisting: true, 24 | providerId: "hashing", 25 | // Credentials login callback, this is called when the user submits the form 26 | async callback(ctx, parsed) { 27 | const registerFn = (userData: Partial, newUser: boolean) => { 28 | if (parsed.password.length < 12) { 29 | throw new APIError("BAD_REQUEST", { code: "WEAK_PASSWORD", message: "Password must be at least 12 characters long." }); 30 | } 31 | if (!userData.name) { 32 | userData.name = parsed.email.split("@")[0]; 33 | } 34 | if (!userData.image) { 35 | userData.image = newUser ? "http://example.com/new.png" : "http://example.com/linked.png"; 36 | } 37 | return userData; 38 | }; 39 | 40 | return { 41 | async onSignIn(userData, user, account) { 42 | if (!account) { 43 | // Because linkAccountIfExisting is true, this can happen: 44 | // First time sign in using this provider, but on an existing user (created by another provider, email/password, social, etc...) 45 | return registerFn({ 46 | name: user.name, 47 | image: user.image 48 | }, false); 49 | } 50 | 51 | // Check password 52 | if(!account.password) { 53 | // hash the password to prevent timing attacks 54 | const hashedPassword = await argon2.hash(parsed.password); 55 | console.error("Account password not found."); 56 | return null; 57 | } 58 | if (!(await argon2.verify(account.password, parsed.password))) { 59 | console.error("Password didn't match."); 60 | return null; 61 | } 62 | // Password matches, return user data 63 | return userData; 64 | }, 65 | onSignUp(userData) { 66 | // Account creation, sign up this provider and user 67 | return registerFn(userData, true); 68 | }, 69 | async onLinkAccount(user) { 70 | // This callback can't throw errors, because the user already was created 71 | return { 72 | password: await argon2.hash(parsed.password), 73 | }; 74 | }, 75 | }; 76 | }, 77 | }) 78 | ], 79 | }); -------------------------------------------------------------------------------- /examples/hashing/server.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { auth } from "./auth.js"; 4 | import { getApp } from "../app.js"; 5 | 6 | const app = getApp(auth); 7 | export default app; -------------------------------------------------------------------------------- /examples/ldap-auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth, User } from "better-auth"; 2 | import { mongodbAdapter } from "better-auth/adapters/mongodb"; 3 | import { fromNodeHeaders } from "better-auth/node"; 4 | import { openAPI } from "better-auth/plugins"; 5 | import { Request } from "express"; 6 | import { MongoClient } from "mongodb"; 7 | import { credentials } from "../../src/credentials/index.js"; 8 | import { authenticate } from "ldap-authentication"; 9 | import * as z from "zod"; 10 | import { mkdir, writeFile } from "fs/promises"; 11 | 12 | // https://www.better-auth.com/docs/adapters/mongo 13 | // For MongoDB, we don't need to generate or migrate the schema. 14 | const client = new MongoClient(process.env.DB_URL_AUTH!); 15 | const db = client.db(); 16 | 17 | async function saveImageToDisk(id: string, base64Jpeg: string) { 18 | await mkdir("./public/images/users", { recursive: true }); 19 | 20 | const imageUrl = `/public/images/users/${id}.jpg`; 21 | await writeFile("."+imageUrl, base64Jpeg, "base64"); 22 | 23 | return imageUrl; 24 | } 25 | 26 | export const auth = betterAuth({ 27 | database: mongodbAdapter(db), 28 | emailAndPassword: { 29 | enabled: true, 30 | }, 31 | user: { 32 | additionalFields: { 33 | // Add additional fields to the user model 34 | description: { 35 | type: "string", 36 | returned: true, 37 | required: false, 38 | }, 39 | groups: { 40 | type: "string[]", 41 | returned: true, 42 | required: false, 43 | defaultValue: [], 44 | } 45 | } 46 | }, 47 | plugins: [ 48 | openAPI(), 49 | credentials({ 50 | // User type to use, this will be used to type the user in the callback 51 | // This way the zod schema will infer correctly, otherwise you would have to pass both generic types explicitly 52 | UserType: {} as User & { 53 | description: string, 54 | groups: string[] 55 | }, 56 | // Sucessful authenticated users will have a 'ldap' Account linked to them, no matter if they previously exists or not 57 | autoSignUp: true, 58 | linkAccountIfExisting: true, 59 | providerId: "ldap", 60 | inputSchema: z.object({ 61 | credential: z.string().min(1), 62 | password: z.string().min(1) 63 | }), 64 | // Credentials login callback, this is called when the user submits the form 65 | async callback(ctx, parsed) { 66 | // Login via LDAP and return user data 67 | const secure = process.env.LDAP_URL!.startsWith("ldaps://"); 68 | const ldapResult = await authenticate({ 69 | // LDAP client connection options 70 | ldapOpts: { 71 | url: process.env.LDAP_URL!, 72 | connectTimeout: 5000, 73 | strictDN: true, 74 | ...(secure ? {tlsOptions: { minVersion: "TLSv1.2" }} : {}) 75 | }, 76 | adminDn: process.env.LDAP_BIND_DN, 77 | adminPassword: process.env.LDAP_PASSW, 78 | userSearchBase: process.env.LDAP_BASE_DN, 79 | usernameAttribute: process.env.LDAP_SEARCH_ATTR, 80 | // https://github.com/shaozi/ldap-authentication/issues/82 81 | //attributes: ['jpegPhoto;binary', 'displayName', 'uid', 'mail', 'cn'], 82 | explicitBufferAttributes: ["jpegPhoto"], 83 | 84 | username: parsed.credential, 85 | userPassword: parsed.password, 86 | 87 | groupClass: "Group", 88 | groupsSearchBase: process.env.LDAP_BASE_DN, 89 | groupMemberAttribute: "member", 90 | }); 91 | const uid = ldapResult[process.env.LDAP_SEARCH_ATTR!]; 92 | 93 | return { 94 | // Required to return email to identify the user, as the inputSchema does not have it 95 | email: (Array.isArray(ldapResult.mail) ? ldapResult.mail[0] : ldapResult.mail) || `${uid}@local`, 96 | 97 | // Atributes that will be saved in the user, regardless if is sign-in or sign-up 98 | name: ldapResult.displayName || uid, 99 | description: ldapResult.description || "", 100 | groups: ldapResult.groups && Array.isArray(ldapResult.groups) ? ldapResult.groups : [], 101 | 102 | // Callback that is called after sucessully sign-in (Existing user) 103 | async onSignIn(userData, user, account) { 104 | if(!account) { 105 | // If is the first time this provider is used 106 | if(ldapResult.jpegPhoto && !user.image) { 107 | userData.image = await saveImageToDisk(ldapResult.uid, ldapResult.jpegPhoto); 108 | } 109 | } 110 | 111 | return userData; 112 | }, 113 | 114 | // Callback that is called after sucessful sign-up (New user) 115 | async onSignUp(userData) { 116 | // Only on sign-up we save the image to disk and save the url in the user data 117 | if(ldapResult.jpegPhoto) { 118 | userData.image = await saveImageToDisk(ldapResult.uid, ldapResult.jpegPhoto); 119 | } 120 | 121 | return userData; 122 | }, 123 | 124 | // Callback that is called when a account is linked to an existing user or when the user signs up first time 125 | async onLinkAccount(user) { 126 | return { 127 | accountId: ldapResult.dn 128 | }; 129 | }, 130 | }; 131 | }, 132 | }) 133 | ], 134 | }); -------------------------------------------------------------------------------- /examples/ldap-auth/server.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { auth } from "./auth.js"; 4 | import { getApp } from "../app.js"; 5 | 6 | const app = getApp(auth); 7 | export default app; -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | credentialsClient 3 | } from "./src/credentials/client.js"; 4 | 5 | export { 6 | credentials, 7 | CallbackResult, 8 | CredentialOptions, 9 | } from "./src/credentials/index.js"; 10 | 11 | export { 12 | defaultCredentialsSchema 13 | } from "./src/credentials/schema.js"; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-auth-credentials-plugin", 3 | "version": "0.3.0", 4 | "description": "Generic credentials authentication plugin for Better Auth (To auth with ldap, external API, etc...)", 5 | "author": "Erick L. Weil", 6 | "license": "MIT", 7 | "keywords": [ 8 | "better-auth", 9 | "betterauth", 10 | "auth", 11 | "authentication", 12 | "credentials", 13 | "ldap", 14 | "active-directory", 15 | "ad", 16 | "plugin", 17 | "provider" 18 | ], 19 | "main": "dist/index.js", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/erickweil/better-auth-credentials-plugin.git" 23 | }, 24 | "engines": { 25 | "node": ">=18" 26 | }, 27 | "files": [ 28 | "dist" 29 | ], 30 | "types": "dist/index.d.ts", 31 | "scripts": { 32 | "example:ldap": "node ./dist/examples/ldap-auth/server.js", 33 | "example:basic": "node ./dist/examples/basic/server.js", 34 | "example:external": "node ./dist/examples/external-api/server.js", 35 | "build": "tsc", 36 | "lint": "npx eslint ./ --ext .ts", 37 | "test": "vitest run" 38 | }, 39 | "dependencies": { 40 | }, 41 | "peerDependencies": { 42 | "better-auth": "^1.2.12", 43 | "zod": "^3.25.0 || ^4.0.0" 44 | }, 45 | "devDependencies": { 46 | "@better-auth-kit/tests": "^0.2.0", 47 | "@eslint/compat": "^1.3.2", 48 | "@types/express": "^5.0.3", 49 | "@types/node": "^24.3.0", 50 | "@types/supertest": "^6.0.3", 51 | "@typescript-eslint/eslint-plugin": "^8.41.0", 52 | "@typescript-eslint/parser": "^8.41.0", 53 | "@vitest/coverage-v8": "^3.2.4", 54 | "better-auth": "^1.3.7", 55 | "argon2": "^0.44.0", 56 | "dotenv": "^17.2.1", 57 | "eslint": "^9.34.0", 58 | "eslint-plugin-import": "^2.32.0", 59 | "express": "^5.1.0", 60 | "happy-dom": "^18.0.1", 61 | "jsdom": "^26.1.0", 62 | "ldap-authentication": "github:shaozi/ldap-authentication#master", 63 | "mongodb": "^6.19.0", 64 | "nodemon": "^3.1.10", 65 | "supertest": "^7.1.4", 66 | "typescript": "^5.9.2", 67 | "vitest": "^3.2.4", 68 | "vitest-mongodb": "^1.0.1", 69 | "zod": "^4.1.5" 70 | }, 71 | "type": "module" 72 | } 73 | -------------------------------------------------------------------------------- /src/credentials/client.ts: -------------------------------------------------------------------------------- 1 | // Adaptado de: https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/username/client.ts 2 | import { BetterAuthClientPlugin, User } from "better-auth"; 3 | 4 | import type { credentials } from "./index.js"; 5 | import { defaultCredentialsSchema } from "./schema.js"; 6 | import { Zod34Schema } from "../utils/zod.js"; 7 | 8 | export const credentialsClient = () => { 9 | return { 10 | id: "credentials", 11 | $InferServerPlugin: {} as ReturnType>, 12 | } satisfies BetterAuthClientPlugin; 13 | }; -------------------------------------------------------------------------------- /src/credentials/error-codes.ts: -------------------------------------------------------------------------------- 1 | // Adaptado de: https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/username/error-codes.ts 2 | export const CREDENTIALS_ERROR_CODES = { 3 | INVALID_CREDENTIALS: "invalid credentials", 4 | EMAIL_REQUIRED: "email is required", 5 | EMAIL_NOT_VERIFIED: "email not verified", 6 | UNEXPECTED_ERROR: "unexpected error", 7 | USERNAME_IS_ALREADY_TAKEN: "username is already taken. please try another.", 8 | }; -------------------------------------------------------------------------------- /src/credentials/index.ts: -------------------------------------------------------------------------------- 1 | // Adaptado de https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/username/index.ts 2 | // e https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/api/routes/sign-in.ts 3 | 4 | import { APIError, EndpointContext } from "better-call"; 5 | import { Account, BetterAuthPlugin, User } from "better-auth"; 6 | import { createAuthEndpoint, sendVerificationEmailFn } from "better-auth/api"; 7 | import { CREDENTIALS_ERROR_CODES as CREDENTIALS_ERROR_CODES } from "./error-codes.js"; 8 | import { setSessionCookie } from "better-auth/cookies"; 9 | import { defaultCredentialsSchema, DefaultCredentialsType } from "./schema.js"; 10 | import { inferZod34, Zod34Schema } from "../utils/zod.js"; 11 | 12 | type GetBodyParsed = Z extends Zod34Schema ? inferZod34 : { 13 | email: string; 14 | password: string; 15 | rememberMe?: boolean | undefined; 16 | }; 17 | type MaybePromise = T | Promise; 18 | 19 | export type CallbackResult = (Partial & { 20 | onSignUp?: (userData: Partial) => MaybePromise | null>; 21 | onSignIn?: (userData: Partial, user: U, account: Account | null) => MaybePromise | null>; 22 | onLinkAccount?: (user: U) => MaybePromise>; 23 | }) | null | undefined; 24 | 25 | export type CredentialOptions = { 26 | /** 27 | * Function that receives the credential and password and returns a Promise with the partial user data to be updated. 28 | * 29 | * If the user does not exists it will be created if `autoSignUp` is true, in this case a 30 | * the returned user data will be used to create the user, otherwise, if the user exists, it will be updated with the returned user data. 31 | * 32 | * If a custom inputSchema is set and it hasn't an `email` field, then you should return the `email` field to uniquely identify the user (Better auth can't operate without emails anyway). 33 | * 34 | * The `onSignIn` and `onSignUp` callbacks are optional, but if returned they will be called to handle updating the user data differently based if the user is signing in or signing up. 35 | * 36 | * The `onLinkAccount` callback is called whenever a Account is created or if the user already exists and an account is linked to the user, use it to store custom data on the Account. 37 | */ 38 | callback: ( 39 | ctx: EndpointContext, 40 | parsed: GetBodyParsed 41 | ) => 42 | MaybePromise>; 43 | 44 | /** 45 | * Schema for the input data, if not provided it will use the default schema that mirrors default email and password with rememberMe option. 46 | * 47 | * (Until version 0.2.2 it had to be a zod/v3 schema, now it works with zod/v4 also) 48 | */ 49 | inputSchema?: Z; 50 | 51 | /** 52 | * Whether to sign up the user if they successfully authenticate but do not exist locally 53 | * @default false 54 | */ 55 | autoSignUp?: boolean; 56 | 57 | /** 58 | * If is allowed to link an account to an existing user without an Account of this provider (No effect if autoSignUp is false). 59 | * 60 | * Basically, if the user already exists, but with another provider (e.g. email and password), if this is true a 61 | * new Account will be created and linked to this user (as if new login method), otherwise it will throw an error. 62 | * @default false 63 | */ 64 | linkAccountIfExisting?: boolean; 65 | 66 | /** 67 | * The Id of the provider to be used for the account created, fallback to "credential", the same used by the email and password flow. 68 | * 69 | * Obs: If you are using this plugin with the email and password plugin enabled and did not change the providerId, users that have a password set will not be able to log in with this credentials plugin. 70 | * @default "credential" 71 | */ 72 | providerId?: string; 73 | 74 | /** 75 | * The path for the endpoint 76 | * @default "/sign-in/credentials" 77 | */ 78 | path?: P; 79 | 80 | /** 81 | * This is used to infer the User type to be used, never used otherwise. If not provided it will be the default User type. 82 | * 83 | * For example, to add a lastLogin input value: 84 | * @example {} as User & {lastLogin: Date} 85 | */ 86 | UserType?: U; 87 | }; 88 | 89 | /** 90 | * Customized Credentials plugin for BetterAuth. 91 | * 92 | * The options allow you to customize the input schema, the callback function, and other behaviors. 93 | * 94 | * Summary of the stages of this authentication flow: 95 | * 1. Validate the input data against `inputSchema` 96 | * 2. Call the `callback` function 97 | * - If the callback throws an error, or doesn't return a object with user data, a generic 401 Unauthorized error is thrown. 98 | * 3. Find the user by email (given by callback or parsed input), if exists proceed to [SIGN IN], if not [SIGN UP] (only when `autoSignUp` is true). 99 | * 100 | * **[SIGN IN]** 101 | * 102 | * 4. Find the Account with the providerId 103 | * - If the account is not found, and `linkAccountIfExisting` or `autoSignUp` is false, login fails with a 401 Unauthorized error. 104 | * 5. If provided, Call the `onSignIn` callback function, but yet don't update the user data. 105 | * 6. If no Account was found on step 4. call the `onLinkAccount` callback function to get the account data to be stored, and then create a new Account for the user with the providerId. 106 | * 7. Update the user with the provided data (Either returned by the auth callback function or the `onSignIn` callback function). 107 | * 108 | * **[SIGN UP]** 109 | * 110 | * 4. If provided, call the `onSignUp` callback function to get the user data to be stored. 111 | * 5. Create a new User with the provided data (Either returned by the auth callback function or the `onSignUp` callback function). 112 | * 5. If provided, call the `onLinkAccount` callback function to get the account data to be stored 113 | * 6. Then create a new Account for the user with the providerId. 114 | * 115 | * **[AUTHENTICATED!]** 116 | * 117 | * 6. Create a new session for the user and set the session cookie. 118 | * 7. Return the user data and the session token. 119 | * 120 | * @example 121 | * ```ts 122 | * credentials({ 123 | * autoSignUp: true, 124 | * callback: async (ctx, parsed) => { 125 | * // 1. Verify the credentials 126 | * 127 | * // 2. On success, return the user data 128 | * return { 129 | * email: parsed.email 130 | * }; 131 | * }) 132 | */ 133 | export const credentials = (options: CredentialOptions) => { 134 | const zodSchema = (options.inputSchema || defaultCredentialsSchema) as Z; 135 | 136 | return { 137 | id: "credentials", 138 | endpoints: { 139 | signInUsername: createAuthEndpoint( 140 | // Endpoints are inferred from the server plugin by adding a $InferServerPlugin key to the client plugin. 141 | // Without this 'as' key the inferred client plugin would not work properly. 142 | (options.path || "/sign-in/credentials") as P, 143 | { 144 | method: "POST", 145 | body: zodSchema, 146 | metadata: { 147 | openapi: { 148 | summary: "Sign in with Credentials", 149 | description: "Sign in with credentials using the user's email and password or other configured fields.", 150 | responses: { 151 | 200: { 152 | description: "Success", 153 | content: { 154 | "application/json": { 155 | schema: { 156 | type: "object", 157 | properties: { 158 | token: { 159 | type: "string", 160 | description: 161 | "Session token for the authenticated session", 162 | }, 163 | user: { 164 | $ref: "#/components/schemas/User", 165 | }, 166 | }, 167 | required: ["token", "user"], 168 | }, 169 | }, 170 | }, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | async (ctx) => { 177 | // ================== 1. Validate the input data =================== 178 | // TODO: double check if the body was *really* parsed against the zod schema 179 | const parsed = ctx.body as GetBodyParsed; 180 | if(!parsed || typeof parsed !== "object") { 181 | ctx.context.logger.error("Invalid request body", { credentials }); 182 | throw new APIError("UNPROCESSABLE_ENTITY", { 183 | message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR 184 | }); 185 | } 186 | 187 | // ================== 2. Calling Callback Function =================== 188 | let callbackResult: CallbackResult; 189 | try { 190 | callbackResult = await options.callback(ctx, parsed); 191 | 192 | if (!callbackResult) { 193 | ctx.context.logger.error("Authentication failed, callback didn't returned user data", { credentials }); 194 | throw new APIError("UNAUTHORIZED", { 195 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 196 | }); 197 | } 198 | } catch (error) { 199 | ctx.context.logger.error("Authentication failed", { error, credentials }); 200 | 201 | throw new APIError("UNAUTHORIZED", { 202 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 203 | }); 204 | } 205 | let {onSignIn, onSignUp, onLinkAccount, email, ..._userData} = callbackResult; 206 | let userData: Partial = _userData as Partial; 207 | 208 | // Fallback email from body if not provided in callback result 209 | if(!email) { 210 | email = "email" in parsed && typeof parsed.email === "string" ? parsed.email : undefined; 211 | if(!email) { 212 | ctx.context.logger.error("Email is required for credentials authentication", { credentials }); 213 | throw new APIError("UNPROCESSABLE_ENTITY", { 214 | message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR, 215 | details: "Email is required for credentials authentication", 216 | }); 217 | } 218 | } 219 | email = email.toLowerCase(); 220 | 221 | // ================== 3. Find User by email =================== 222 | let user: U | null = await ctx.context.adapter.findOne({ 223 | model: "user", 224 | where: [ 225 | { 226 | field: "email", 227 | value: email, 228 | }, 229 | ], 230 | }); 231 | 232 | // If no user is found and autoSignUp is not enabled, throw an error 233 | if(!options.autoSignUp && !user) { 234 | // TODO: timing attack mitigation? 235 | ctx.context.logger.error("User not found", { credentials }); 236 | throw new APIError("UNAUTHORIZED", { 237 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 238 | }); 239 | } 240 | 241 | // If email verification is required, return early 242 | if ( 243 | user && !user.emailVerified && 244 | ctx.context.options.emailAndPassword?.requireEmailVerification 245 | ) { 246 | await sendVerificationEmailFn(ctx, user); 247 | throw new APIError("FORBIDDEN", { 248 | message: CREDENTIALS_ERROR_CODES.EMAIL_NOT_VERIFIED, 249 | }); 250 | } 251 | 252 | let account: Account | null = null; 253 | if(!user) { 254 | // =================================================================== 255 | // = SIGN UP = 256 | // = Create a new User and Account, for this provider = 257 | // =================================================================== 258 | // 259 | 260 | // ================== 4. create new User ==================== 261 | try { 262 | if(onSignUp && typeof onSignUp === "function") { 263 | const newData = await onSignUp({email: email, ...userData}); 264 | if(!newData) { 265 | throw new Error("onSignUp callback returned null, failed sign up"); 266 | } 267 | userData = newData; 268 | } 269 | 270 | if(!userData || !email) { 271 | throw new APIError("UNPROCESSABLE_ENTITY", { 272 | message: CREDENTIALS_ERROR_CODES.EMAIL_REQUIRED, 273 | details: "User data must include at least email", 274 | }); 275 | } 276 | 277 | delete userData.email; 278 | const { name, ...restUserData } = userData; 279 | user = await ctx.context.internalAdapter.createUser({ 280 | email: email, 281 | name: name!, // Yes, the type is wrong, NAME IS OPTIONAL 282 | emailVerified: false, 283 | ...restUserData, 284 | }, ctx); 285 | } catch (e) { 286 | ctx.context.logger.error("Failed to create user", e); 287 | if (e instanceof APIError) { 288 | throw e; 289 | } 290 | throw new APIError("UNAUTHORIZED", { 291 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 292 | }); 293 | } 294 | if (!user) { 295 | throw new APIError("UNPROCESSABLE_ENTITY", { 296 | message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR, 297 | }); 298 | } 299 | 300 | // ================== 5. create new Account ==================== 301 | let accountData = {}; 302 | if(onLinkAccount && typeof onLinkAccount === "function") { 303 | accountData = await onLinkAccount(user); 304 | } 305 | account = await ctx.context.internalAdapter.linkAccount( 306 | { 307 | userId: user.id, 308 | providerId: options.providerId || "credential", 309 | accountId: user.id, 310 | ...accountData 311 | }, 312 | ctx, 313 | ); 314 | 315 | // If the user is created, we can send the verification email if required 316 | if ( 317 | !user.emailVerified && 318 | (ctx.context.options.emailVerification?.sendOnSignUp || 319 | ctx.context.options.emailAndPassword?.requireEmailVerification) 320 | ) { 321 | await sendVerificationEmailFn(ctx, user); 322 | 323 | // If email verification is required, just return the user without a token and no session is created (this mimics the behavior of the email and password sign-up flow) 324 | if(ctx.context.options.emailAndPassword?.requireEmailVerification) { 325 | return ctx.json({ 326 | token: null, 327 | user: { 328 | id: user.id, 329 | email: user.email, 330 | name: user.name, 331 | image: user.image, 332 | emailVerified: user.emailVerified, 333 | createdAt: user.createdAt, 334 | updatedAt: user.updatedAt, 335 | }, 336 | }); 337 | } 338 | } 339 | } else { 340 | // =================================================================== 341 | // = SIGN IN = 342 | // = Find/Link Account, for this provider = 343 | // =================================================================== 344 | // 345 | 346 | // =============== 4. Get the user account with the chosen provider ============== 347 | account = await ctx.context.adapter.findOne({ 348 | model: "account", 349 | where: [ 350 | { 351 | field: "userId", 352 | value: user.id, 353 | }, 354 | { 355 | field: "providerId", 356 | value: options.providerId || "credential", 357 | }, 358 | ], 359 | }); 360 | 361 | if((!options.autoSignUp || !options.linkAccountIfExisting) && !account) { 362 | ctx.context.logger.error("User exists but no account found for this provider", { credentials }); 363 | throw new APIError("UNAUTHORIZED", { 364 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 365 | }); 366 | } 367 | 368 | if(account && account.providerId === "credential" && account.password) { 369 | ctx.context.logger.error("Shouldn't login with credentials, this user has a account with password", { credentials }); 370 | throw new APIError("UNAUTHORIZED", { 371 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 372 | }); 373 | } 374 | 375 | // =============== 5. Update user data ============== 376 | try { 377 | if(onSignIn && typeof onSignIn === "function") { 378 | const newData = await onSignIn({email: email, ...userData}, user, account); 379 | if(!newData) { 380 | throw new Error("onSignIn callback returned null, failed on sign in"); 381 | } 382 | userData = newData; 383 | } 384 | } catch (e) { 385 | ctx.context.logger.error("Failed to update user data on sign in", e); 386 | if (e instanceof APIError) { 387 | throw e; 388 | } 389 | throw new APIError("UNAUTHORIZED", { 390 | message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS, 391 | }); 392 | } 393 | 394 | // Doing the linking after onSignIn callback, so if it fails no account is created 395 | if(!account) { 396 | // Create an account for the user if it doesn't exist 397 | let accountData = {}; 398 | if(onLinkAccount && typeof onLinkAccount === "function") { 399 | accountData = await onLinkAccount(user); 400 | } 401 | account = await ctx.context.internalAdapter.linkAccount( 402 | { 403 | userId: user.id, 404 | providerId: options.providerId || "credential", 405 | accountId: user.id, 406 | ...accountData 407 | }, 408 | ctx, 409 | ); 410 | } 411 | 412 | // Update the user with the new data (excluding email) 413 | if(userData) { 414 | delete userData.email; 415 | if(Object.keys(userData).length > 0) { 416 | user = (await ctx.context.internalAdapter.updateUser(user.id, userData, ctx)) as U; 417 | } 418 | } 419 | } 420 | 421 | // =================================================================== 422 | // = AUTHENTICATED! = 423 | // = Proceed with login flow = 424 | // =================================================================== 425 | 426 | const rememberMe = "rememberMe" in parsed ? parsed.rememberMe : false; 427 | const session = await ctx.context.internalAdapter.createSession( 428 | user.id, 429 | ctx, 430 | rememberMe === false, 431 | ); 432 | if (!session) { 433 | ctx.context.logger.error("Failed to create session"); 434 | throw new APIError("BAD_REQUEST", { 435 | message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR 436 | }); 437 | } 438 | await setSessionCookie( 439 | ctx, 440 | { session, user }, 441 | rememberMe === false, 442 | ); 443 | 444 | // =============== Response with user data ============== 445 | // TODO: how to return all fields with { returned: true } configured? 446 | return ctx.json({ 447 | token: session.token, 448 | user: { 449 | id: user.id, 450 | email: user.email, 451 | name: user.name, 452 | image: user.image, 453 | emailVerified: user.emailVerified, 454 | createdAt: user.createdAt, 455 | updatedAt: user.updatedAt, 456 | }, 457 | }); 458 | }, 459 | ), 460 | }, 461 | $ERROR_CODES: CREDENTIALS_ERROR_CODES, 462 | } satisfies BetterAuthPlugin; 463 | }; -------------------------------------------------------------------------------- /src/credentials/schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | import * as z3 from "zod/v3"; 3 | 4 | import { isZodV4 } from "../utils/zod.js"; 5 | 6 | //check if zod is v3 or v4 7 | let schema; 8 | if(isZodV4(z.object({ test: z.string() }))) { 9 | schema = z.object({ 10 | email: z.email().min(1).meta({ 11 | description: "The email of the user", 12 | }), 13 | password: z.string().min(1).meta({ 14 | description: "The password of the user", 15 | }), 16 | rememberMe: z.boolean().optional().meta({ 17 | description: "Remember the user session", 18 | }), 19 | }); 20 | } else { 21 | console.log("Using Zod v3"); 22 | schema = z3.object({ 23 | email: z3.string({ 24 | description: "The email of the user", 25 | }).min(1).email(), 26 | password: z3.string({ 27 | description: "The password of the user", 28 | }).min(1), 29 | rememberMe: z3.boolean({ 30 | description: "Remember the user session", 31 | }).optional(), 32 | }); 33 | } 34 | 35 | export type DefaultCredentialsType = { 36 | email: string; 37 | password: string; 38 | rememberMe?: boolean | undefined; 39 | }; 40 | export const defaultCredentialsSchema = schema; -------------------------------------------------------------------------------- /src/utils/zod.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | import * as z3 from "zod/v3"; 3 | import * as z4 from "zod/v4/core"; 4 | 5 | export type Zod34Schema = z3.ZodTypeAny | z4.$ZodType; 6 | 7 | export const isZodV4 = (schema: Zod34Schema) => { 8 | return "_zod" in schema; 9 | }; 10 | 11 | export type inferZod34 = T extends z3.ZodTypeAny ? z3.infer : T extends z4.$ZodType ? z4.infer : never; -------------------------------------------------------------------------------- /test/examples/basic.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import app from "../../examples/basic/server.js"; 3 | import supertest from "supertest"; 4 | import { testCases } from "../test-helpers.js"; 5 | 6 | describe("Fake login, should fail when email and password are different", () => { 7 | const req = supertest(app); 8 | 9 | test("Create normal email & password account", async () => { 10 | const response = await req 11 | .post("/api/auth/sign-up/email") 12 | .set("Accept", "application/json") 13 | .send({ 14 | name: "new-user", 15 | email: "new.user@example.com", 16 | password: "new.user@example.com" 17 | }) 18 | .expect(200); 19 | 20 | expect(response.body).toBeTruthy(); 21 | }); 22 | 23 | testCases("success cases", [ 24 | { email: "basic-user1@example.com", password: "basic-user1@example.com" }, 25 | { email: "basic-user2@example.com", password: "basic-user2@example.com" }, 26 | { email: "basic-user2@example.com", password: "basic-user2@example.com" } // Usuário existente 27 | ], async (testCase) => { 28 | let cookies = ""; 29 | { // 1 - Deve fazer login 30 | const response = await req 31 | .post("/api/auth/sign-in/credentials") 32 | .set("Accept", "application/json") 33 | .send({ 34 | email: testCase.email, 35 | password: testCase.password 36 | }) 37 | .expect(200); 38 | 39 | expect(response?.body).toBeTruthy(); 40 | const { user } = response.body; 41 | expect(user).toBeTruthy(); 42 | expect(user.email).toBe(testCase.email); 43 | 44 | // Verifica se o cookie de sessão foi retornado 45 | const setCookieHeader = response.headers["set-cookie"]; 46 | expect(setCookieHeader).toBeTruthy(); 47 | cookies = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader; 48 | } 49 | 50 | { // 2 - /me deve retornar o usuário logado 51 | const response = await req 52 | .get("/me") 53 | .set("Accept", "application/json") 54 | .set("Cookie", cookies) // Envia o cookie de sessão 55 | .expect(200); 56 | expect(response?.body).toBeTruthy(); 57 | const { user, session } = response.body; 58 | expect(user).toBeTruthy(); 59 | expect(session).toBeTruthy(); 60 | expect(user.email).toBe(testCase.email); 61 | } 62 | }); 63 | 64 | testCases("fail cases", [ 65 | { status: 400 }, 66 | { status: 400, email: "", password: "" }, 67 | { status: 400, email: null, password: null }, 68 | { status: 400, email: "invalid", password: "invalid" }, 69 | { status: 401, email: "abcd@example.com", password: "wrongpassword" }, 70 | { status: 401, email: "basic-user1@example.com", password: "wrongpassword" }, 71 | { status: 401, email: "basic-user1@example.com", password: "basic-user2@example.com" }, 72 | { status: 401, email: "new.user@example.com", password: "new.user@example.com" }, // Deve falhar pois esse usuário foi criado com o método de sign-up normal 73 | ], async (testCase) => { 74 | const { status, ...body } = testCase; 75 | 76 | const response = await req 77 | .post("/api/auth/sign-in/credentials") 78 | .set("Accept", "application/json") 79 | .send(body) 80 | .expect(status); 81 | }); 82 | 83 | }); -------------------------------------------------------------------------------- /test/examples/external-api.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import app from "../../examples/external-api/server.js"; 3 | import { testCases } from "../test-helpers.js"; 4 | import { authClient } from "../../examples/external-api/client.js"; 5 | 6 | describe("External API, make request to auth", () => { 7 | 8 | const server = app.listen(3000); 9 | 10 | testCases("success cases", [ 11 | { username: "external1", password: "password1" }, 12 | { username: "external2", password: "password2" }, 13 | { username: "external2", password: "password2" } // Usuário existente 14 | ], async (testCase) => { 15 | 16 | let sessionToken: string | undefined; 17 | { 18 | const { data, error } = await authClient.signIn.external({ 19 | username: testCase.username, 20 | password: testCase.password, 21 | fetchOptions: { 22 | onResponse(context) { 23 | const cookie = context.response.headers.getSetCookie().filter((c) => c.includes("better-auth.session_token="))[0]; 24 | if (cookie) { 25 | // Extract the session token from the cookie 26 | sessionToken = cookie.split("=")[1]; 27 | sessionToken = sessionToken?.split(";")[0]; // Remove any attributes like `; Path=/; HttpOnly` 28 | } 29 | }, 30 | } 31 | }); 32 | expect(data).toBeTruthy(); 33 | expect(data?.user).toBeTruthy(); 34 | expect(data?.user.name).toBeTruthy(); 35 | expect((data?.user as any).username).toBeUndefined(); 36 | expect(data?.user?.email).toBe(testCase.username+"@example.com"); 37 | expect(error).toBeFalsy(); 38 | 39 | expect(sessionToken).toBeTruthy(); 40 | expect(sessionToken).toContain("."); 41 | } 42 | 43 | { 44 | const { data, error} = await authClient.getSession({ 45 | fetchOptions: { 46 | headers: { 47 | "Authorization": `Bearer ${sessionToken}` 48 | } 49 | } 50 | }); 51 | expect(error).toBeFalsy(); 52 | expect(data).toBeTruthy(); 53 | 54 | expect(data?.user).toBeTruthy(); 55 | expect(data?.user.email).toBe(testCase.username+"@example.com"); 56 | expect(data?.user.username).toBe(testCase.username); 57 | } 58 | }); 59 | 60 | testCases("fail cases", [ 61 | { status: 400, email: "", password: "" }, 62 | { status: 400, email: null, password: null }, 63 | { status: 400, email: "invalid", password: "invalid" }, 64 | { status: 400, username: "", password: "" }, 65 | { status: 400, username: null, password: null }, 66 | { status: 400, email: "abcd@example.com", password: "wrongpassword" }, 67 | { status: 401, username: "abcd", password: "wrongpassword" }, 68 | { status: 401, username: "external1", password: "wrongpassword" }, 69 | { status: 401, username: "external2", password: "password3" } 70 | ], async (testCase) => { 71 | const { status, ...body } = testCase; 72 | const {data, error} = await authClient.signIn.external(body as any); 73 | console.log(error); 74 | expect(data).toBeFalsy(); 75 | expect(error).toBeTruthy(); 76 | expect(error?.status).toBe(status); 77 | expect(error?.message).toBeTruthy(); 78 | }); 79 | 80 | }); -------------------------------------------------------------------------------- /test/examples/hashing.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, beforeAll } from "vitest"; 2 | import app from "../../examples/hashing/server.js"; // Supondo que o segundo exemplo esteja neste caminho 3 | import supertest from "supertest"; 4 | import { testCases } from "../test-helpers.js"; 5 | 6 | describe("Credentials Provider with Hashing and Account Linking", () => { 7 | const req = supertest(app); 8 | const existingUser = { 9 | email: "existing.user@example.com", 10 | password: "password123", // Senha original do usuário 11 | }; 12 | const credentialsUser = { 13 | email: "credentials.user@example.com", 14 | password: "a-very-secure-password-123", 15 | }; 16 | 17 | // 1. Antes de tudo, crie um usuário padrão (email/senha) para testar a vinculação da conta. 18 | beforeAll(async () => { 19 | await req 20 | .post("/api/auth/sign-up/email") 21 | .set("Accept", "application/json") 22 | .send({ 23 | name: "Existing User", 24 | email: existingUser.email, 25 | password: existingUser.password, 26 | }) 27 | .expect(200); 28 | }); 29 | 30 | test("Should sign up a new user via credentials provider", async () => { 31 | const response = await req 32 | .post("/api/auth/sign-in/credentials") 33 | .set("Accept", "application/json") 34 | .send({ 35 | email: credentialsUser.email, 36 | password: credentialsUser.password, 37 | }) 38 | .expect(200); 39 | 40 | const { user } = response.body; 41 | expect(user).toBeTruthy(); 42 | expect(user.email).toBe(credentialsUser.email); 43 | expect(user.name).toBe("credentials.user"); // Nome gerado automaticamente 44 | expect(user.image).toBe("http://example.com/new.png"); // Imagem de novo usuário 45 | }); 46 | 47 | // 3. Testa o login de um usuário que já se registrou através do provedor 'credentials'. 48 | test("Should sign in an existing credentials user", async () => { 49 | const response = await req 50 | .post("/api/auth/sign-in/credentials") 51 | .set("Accept", "application/json") 52 | .send({ 53 | email: credentialsUser.email, 54 | password: credentialsUser.password, 55 | }) 56 | .expect(200); 57 | 58 | const { user } = response.body; 59 | expect(user).toBeTruthy(); 60 | expect(user.email).toBe(credentialsUser.email); 61 | 62 | // Verifica se o cookie de sessão foi retornado 63 | const setCookieHeader = response.headers["set-cookie"]; 64 | expect(setCookieHeader).toBeTruthy(); 65 | }); 66 | 67 | // 4. Testa a vinculação do provedor 'credentials' a uma conta que já existia (criada no beforeAll). 68 | test("Should link credentials provider to an existing account", async () => { 69 | let cookies = ""; 70 | // Primeiro, faz o login/vinculação 71 | { 72 | const response = await req 73 | .post("/api/auth/sign-in/credentials") 74 | .set("Accept", "application/json") 75 | .send({ 76 | email: existingUser.email, // Email do usuário já existente 77 | password: "new-secure-password-for-linking", 78 | }) 79 | .expect(200); 80 | 81 | const { user } = response.body; 82 | expect(user).toBeTruthy(); 83 | expect(user.email).toBe(existingUser.email); 84 | expect(user.name).toBe("Existing User"); // Deve manter o nome original 85 | expect(user.image).toBe("http://example.com/linked.png"); // Imagem de conta vinculada 86 | 87 | const setCookieHeader = response.headers["set-cookie"]; 88 | expect(setCookieHeader).toBeTruthy(); 89 | cookies = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader; 90 | } 91 | 92 | // Em seguida, verifica se a sessão funciona e retorna o usuário correto 93 | { 94 | const response = await req 95 | .get("/me") 96 | .set("Accept", "application/json") 97 | .set("Cookie", cookies) 98 | .expect(200); 99 | 100 | const { user, session } = response.body; 101 | expect(user).toBeTruthy(); 102 | expect(session).toBeTruthy(); 103 | expect(user.email).toBe(existingUser.email); 104 | } 105 | }); 106 | 107 | // 5. Agrupa todos os casos de falha esperados. 108 | testCases("fail cases for hashing provider", [ 109 | // Senha fraca 110 | { status: 400, email: "weak.password@example.com", password: "short" }, 111 | // Senha incorreta para um usuário existente 112 | { status: 401, email: credentialsUser.email, password: "a-very-secure-but-wrong-password" }, 113 | // Input inválido 114 | { status: 400, email: "bad@request.com", password: "" }, 115 | { status: 400, email: null, password: "some-password" }, 116 | ], async (testCase) => { 117 | const { status, ...body } = testCase; 118 | 119 | const response = await req 120 | .post("/api/auth/sign-in/credentials") 121 | .set("Accept", "application/json") 122 | .send(body) 123 | .expect(status); 124 | 125 | // Para o caso de senha fraca, podemos verificar a mensagem de erro específica 126 | if (body.password === "short") { 127 | expect(response.body.code).toBe("WEAK_PASSWORD"); 128 | } 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/examples/ldap.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import app from "../../examples/ldap-auth/server.js"; 3 | import supertest from "supertest"; 4 | import { testCases } from "../test-helpers.js"; 5 | 6 | // Check if the LDAP server is running 7 | const isLdapConfigured = process.env.LDAP_URL && process.env.LDAP_BIND_DN && process.env.LDAP_PASSW && process.env.LDAP_BASE_DN && process.env.LDAP_SEARCH_ATTR; 8 | 9 | describe.skipIf(!isLdapConfigured)("LDAP, should authenticate users on LDAP server", () => { 10 | const req = supertest(app); 11 | 12 | 13 | test("Create normal email & password account", async () => { 14 | const response = await req 15 | .post("/api/auth/sign-up/email") 16 | .set("Accept", "application/json") 17 | .send({ 18 | name: "new-ldap-user", 19 | email: "bender@planetexpress.com", 20 | password: "bender@planetexpress.com" 21 | }) 22 | .expect(200); 23 | 24 | expect(response.body).toBeTruthy(); 25 | }); 26 | 27 | testCases("success cases", [ 28 | { credential: "fry", password: "fry" }, // Sign-up 29 | { credential: "professor", password: "professor" }, // Sign-up 30 | { credential: "professor", password: "professor" }, // Sign-in 31 | { credential: "bender", password: "bender" }, // Account linking 32 | { credential: "bender", password: "bender" } // Sign-in 33 | ], async (testCase) => { 34 | let cookies = ""; 35 | { // 1 - Deve fazer login 36 | const response = await req 37 | .post("/api/auth/sign-in/credentials") 38 | .set("Accept", "application/json") 39 | .send({ 40 | credential: testCase.credential, 41 | password: testCase.password 42 | }) 43 | .expect(200); 44 | 45 | expect(response?.body).toBeTruthy(); 46 | const { user } = response.body; 47 | expect(user).toBeTruthy(); 48 | expect(user.image).toBeTruthy(); 49 | 50 | // Verifica se o cookie de sessão foi retornado 51 | const setCookieHeader = response.headers["set-cookie"]; 52 | expect(setCookieHeader).toBeTruthy(); 53 | cookies = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader; 54 | } 55 | 56 | { // 2 - /me deve retornar o usuário logado 57 | const response = await req 58 | .get("/me") 59 | .set("Accept", "application/json") 60 | .set("Cookie", cookies) // Envia o cookie de sessão 61 | .expect(200); 62 | expect(response?.body).toBeTruthy(); 63 | const { user, session } = response.body; 64 | expect(user).toBeTruthy(); 65 | expect(session).toBeTruthy(); 66 | 67 | const { image, groups, description } = user; 68 | 69 | expect(image).toBeTruthy(); 70 | expect(groups).toBeTruthy(); 71 | expect(Array.isArray(groups)).toBeTruthy(); 72 | expect(description).toBeTruthy(); 73 | 74 | // Verificar se imagem foi salva 75 | const imageResponse = await req 76 | .get(user.image) 77 | .set("Accept", "image/jpeg") 78 | .expect(200); 79 | expect(imageResponse.headers["content-type"]).toBe("image/jpeg"); 80 | expect(imageResponse.body).toBeTruthy(); 81 | 82 | } 83 | 84 | { 85 | // 3 verificar Account 86 | const response = await req 87 | .get("/api/auth/list-accounts") 88 | .set("Accept", "application/json") 89 | .set("Cookie", cookies) // Envia o cookie de sessão 90 | .expect(200); 91 | 92 | expect(response?.body).toBeTruthy(); 93 | expect(response?.body.length).toBeGreaterThanOrEqual(1); 94 | 95 | const accountLdap = response?.body.find((account: any) => account.provider === "ldap"); 96 | expect(accountLdap).toBeTruthy(); 97 | expect(accountLdap.provider).toBe("ldap"); 98 | expect(accountLdap.accountId).toBeTruthy(); 99 | } 100 | }); 101 | 102 | testCases("fail cases", [ 103 | { status: 400 }, 104 | { status: 400, email: "invalid", password: "invalid" }, 105 | { status: 400, credential: "", password: "" }, 106 | { status: 400, credential: null, password: null }, 107 | { status: 401, credential: "abcd", password: "wrongpassword" }, 108 | { status: 401, credential: "fry", password: "wrongpassword" }, 109 | { status: 401, credential: "amy", password: "password3" } 110 | ], async (testCase) => { 111 | const { status, ...body } = testCase; 112 | 113 | const response = await req 114 | .post("/api/auth/sign-in/credentials") 115 | .set("Accept", "application/json") 116 | .send(body) 117 | .expect(status); 118 | }); 119 | 120 | }); -------------------------------------------------------------------------------- /test/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | declare var __MONGO_URI__: string; -------------------------------------------------------------------------------- /test/plugin.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth, BetterAuthOptions, BetterAuthPlugin, User } from "better-auth"; 2 | import { bearer } from "better-auth/plugins/bearer"; 3 | import { getTestInstance } from "@better-auth-kit/tests"; 4 | import { CredentialOptions, credentials, credentialsClient } from "../index.js"; 5 | import { MongoClient } from "mongodb"; 6 | import { mongodbAdapter } from "better-auth/adapters/mongodb"; 7 | 8 | const client = new MongoClient(process.env.DB_URL_AUTH!); 9 | const db = client.db(); 10 | 11 | export const defaultBetterAuthOptions: BetterAuthOptions = { 12 | database: mongodbAdapter(db), 13 | plugins: [ 14 | bearer() 15 | ], 16 | secret: "better-auth.secret", 17 | emailAndPassword: { 18 | enabled: true, 19 | }, 20 | rateLimit: { 21 | enabled: false, 22 | }, 23 | advanced: { 24 | disableCSRFCheck: true, 25 | cookies: {}, 26 | }, 27 | }; -------------------------------------------------------------------------------- /test/plugin/behaviour-email-passw.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTestInstance } from "@better-auth-kit/tests"; 2 | import { beforeAll, describe, expect, test } from "vitest"; 3 | import { defaultBetterAuthOptions } from "../plugin.js"; 4 | import { credentials, credentialsClient } from "../../index.js"; 5 | import { betterAuth, User } from "better-auth"; 6 | import { APIError } from "better-call"; 7 | import { bearer } from "better-auth/plugins"; 8 | import { testCases } from "../test-helpers.js"; 9 | import * as z from "zod"; 10 | 11 | describe("Test comparison login with email & password vs credentials, should behave similar", () => { 12 | 13 | const _email = "@email.example.com"; 14 | const _cred = "@credential.example.com"; 15 | 16 | const _instance = getTestInstance( 17 | betterAuth({ 18 | ...defaultBetterAuthOptions, 19 | emailAndPassword: { 20 | enabled: true 21 | }, 22 | plugins: [ 23 | bearer(), 24 | credentials({ 25 | autoSignUp: true, 26 | providerId: "behaviour", 27 | inputSchema: z.object({ 28 | email: z.email().min(1), 29 | name: z.string().min(1).optional(), 30 | password: z.string().min(1), 31 | rememberMe: z.boolean().optional(), 32 | }), 33 | callback(ctx, parsed) { 34 | return { 35 | onSignIn(userData, user, account) { 36 | if(!account) return null; 37 | 38 | if (parsed.password !== account.password) { 39 | console.error("Authentication failed mismatch for email and password"); 40 | return null; 41 | } 42 | 43 | return {}; 44 | }, 45 | onSignUp(userData) { 46 | if(parsed.password.length < 8) { 47 | throw new APIError("BAD_REQUEST", { message: "Password must be at least 8 characters long." }); 48 | } 49 | return { 50 | ...userData, 51 | name: parsed.name 52 | }; 53 | }, 54 | // This callback can't throw errors, because the user already was created 55 | onLinkAccount(user) { 56 | // THIS IS JUST FOR TESTING PURPOSES, REAL USE CASES SHOULD STORE HASHED PASSWORDS 57 | return { 58 | password: parsed.password, 59 | }; 60 | }, 61 | }; 62 | } 63 | }), 64 | ] 65 | }), { clientOptions: { plugins: [credentialsClient()] } } 66 | ); 67 | 68 | let client: (Awaited)["client"]; 69 | 70 | beforeAll(async () => { 71 | let instance = await _instance; 72 | client = instance.client; 73 | }); 74 | 75 | testCases("Test cases, comparison of behaviour, signUp cases", [ 76 | { // Fail because password too short 77 | signUp: true, 78 | statusEmail: 400, 79 | statusCred: 400, 80 | body: {name: "test1", email: "test1", password: "passw"}, 81 | match: {} 82 | }, 83 | { // Missing name, works??? 84 | signUp: true, 85 | statusEmail: 200, 86 | statusCred: 200, 87 | body: {email: "test-no-name", password: "password1"}, 88 | match: { 89 | name: undefined, 90 | email: "test-no-name", 91 | emailVerified: false 92 | } 93 | }, 94 | { 95 | signUp: true, 96 | statusEmail: 200, 97 | statusCred: 200, 98 | body: {name: "test1", email: "test1", password: "password1"}, 99 | match: { 100 | name: "test1", 101 | email: "test1", 102 | emailVerified: false 103 | } 104 | }, 105 | { 106 | signUp: true, 107 | statusEmail: 200, 108 | statusCred: 200, 109 | body: {name: "test2", email: "TEST2", password: "password2"}, 110 | match: { 111 | name: "test2", 112 | email: "test2", 113 | emailVerified: false 114 | } 115 | }, 116 | { // Email & password fail because signup duplicated email, credentials fail because password mismatch 117 | signUp: true, 118 | statusEmail: 422, 119 | statusCred: 401, 120 | body: {name: "test1", email: "TEST1", password: "password2"}, 121 | match: {} 122 | }, 123 | 124 | // Sign In cases 125 | { // Fail because password wrong 126 | signUp: false, 127 | statusEmail: 401, 128 | statusCred: 401, 129 | body: {email: "test1", password: "wrong-password"}, 130 | match: {} 131 | }, 132 | { 133 | signUp: false, 134 | statusEmail: 200, 135 | statusCred: 200, 136 | body: {email: "test1", password: "password1"}, 137 | match: { 138 | name: "test1", 139 | email: "test1", 140 | emailVerified: false 141 | } 142 | }, 143 | // Email should be case insensitive 144 | { 145 | signUp: false, 146 | statusEmail: 200, 147 | statusCred: 200, 148 | body: {email: "test2", password: "password2"}, 149 | match: { 150 | name: "test2", 151 | email: "test2", 152 | emailVerified: false 153 | } 154 | }, 155 | { 156 | signUp: false, 157 | statusEmail: 200, 158 | statusCred: 200, 159 | body: {email: "TEST2", password: "password2"}, 160 | match: { 161 | name: "test2", 162 | email: "test2", 163 | emailVerified: false 164 | } 165 | }, 166 | 167 | ], async ({signUp, statusEmail, statusCred, body, match}) => { 168 | const emailResult = signUp ? 169 | await client.signUp.email({...body, email: body.email+_email} as any) 170 | : await client.signIn.email({...body, email: body.email+_email} as any); 171 | const credResult = await client.signIn.credentials({...body, email: body.email+_cred}); 172 | 173 | console.log("Email result:", emailResult); 174 | console.log("Credentials result:", credResult); 175 | 176 | for(let {data, error, status} of [{...emailResult, status: statusEmail}, {...credResult, status: statusCred}]) { 177 | if(status >= 200 && status < 300) { 178 | expect(data).toBeTruthy(); 179 | expect(data?.user).toBeTruthy(); 180 | //expect(data?.user).toMatchObject(match); 181 | for(const key of Object.keys(match)) { 182 | if(key === "email") { 183 | expect(data?.user.email.startsWith(match.email!)).toBe(true); 184 | } else { 185 | expect((data as any)?.user[key]).toBe((match as any)[key]); 186 | } 187 | } 188 | expect(error).toBeNull(); 189 | } else { 190 | expect(error).toBeTruthy(); 191 | expect(error?.status).toBe(status); 192 | expect(data).toBeNull(); 193 | } 194 | } 195 | }); 196 | }); -------------------------------------------------------------------------------- /test/plugin/client.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTestInstance } from "@better-auth-kit/tests"; 2 | import { beforeAll, describe, expect, test } from "vitest"; 3 | import { defaultBetterAuthOptions } from "../plugin.js"; 4 | import { credentials, credentialsClient } from "../../index.js"; 5 | import { betterAuth, User } from "better-auth"; 6 | import * as z from "zod"; 7 | import { bearer } from "better-auth/plugins"; 8 | 9 | describe("Test using the plugin in the client", () => { 10 | const schema = z.object({ 11 | _email: z.email(), 12 | _password: z.string().min(1, "Password is required"), 13 | }); 14 | 15 | let _instance = getTestInstance( 16 | betterAuth({ 17 | ...defaultBetterAuthOptions, 18 | plugins: [ 19 | bearer(), 20 | credentials({ 21 | autoSignUp: true, 22 | path: "/sign-in/my-login", 23 | providerId: "my-login", 24 | inputSchema: schema, 25 | callback(ctx, parsed) { 26 | if (parsed._email !== parsed._password) { 27 | console.error("Authentication failed mismatch for email and password"); 28 | return null; // It is possible to return null to indicate failure 29 | } else { 30 | return { 31 | email: parsed._email, 32 | name: parsed._email.split("@")[0] 33 | }; 34 | } 35 | } 36 | }), 37 | ] 38 | }), 39 | { 40 | clientOptions: { 41 | plugins: [credentialsClient()], 42 | }, 43 | }, 44 | ); 45 | let instance: Awaited; 46 | 47 | beforeAll(async () => { 48 | instance = await _instance; 49 | }); 50 | 51 | test("Should be able to authenticate", async () => { 52 | const { client } = instance; 53 | 54 | const {data, error} = await client.signIn.myLogin({ 55 | _email: "plugin_user@example.com", 56 | _password: "plugin_user@example.com" 57 | }); 58 | 59 | expect(error).toBeNull(); 60 | expect(data).toBeDefined(); 61 | 62 | expect(data?.user).toBeDefined(); 63 | expect(data?.user?.email).toBe("plugin_user@example.com"); 64 | expect(data?.user?.name).toBe("plugin_user"); 65 | }); 66 | 67 | test("Should'nt authenticate", async () => { 68 | const { client } = instance; 69 | 70 | const {data, error} = await client.signIn.myLogin({ 71 | _email: "plugin_user@example.com", 72 | _password: "wrong-password" 73 | }); 74 | 75 | expect(error).toBeDefined(); 76 | expect(error?.status).toBe(401); 77 | expect(data).toBeNull(); 78 | }); 79 | 80 | test("Should'nt authenticate, existing user different provider", async () => { 81 | const { client } = instance; 82 | 83 | await client.signUp.email({ 84 | name: "Plugin User 2", 85 | email: "plugin_user2@example.com", 86 | password: "plugin_user2@example.com" 87 | }); 88 | 89 | const {data, error} = await client.signIn.myLogin({ 90 | _email: "plugin_user2@example.com", 91 | _password: "plugin_user2@example.com" 92 | }); 93 | 94 | expect(error).toBeDefined(); 95 | expect(error?.status).toBe(401); 96 | expect(data).toBeNull(); 97 | }); 98 | }); -------------------------------------------------------------------------------- /test/plugin/config.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { credentials } from "../../src/credentials/index.js"; 3 | import * as z from "zod"; 4 | import { Account, betterAuth, BetterAuthPlugin, User } from "better-auth"; 5 | import { MongoClient } from "mongodb"; 6 | import { mongodbAdapter } from "better-auth/adapters/mongodb"; 7 | import { getApp } from "../../examples/app.js"; 8 | import supertest from "supertest"; 9 | import TestAgent from "supertest/lib/agent.js"; 10 | import { testCases } from "../test-helpers.js"; 11 | 12 | const client = new MongoClient(process.env.DB_URL_AUTH!); 13 | const db = client.db(); 14 | 15 | function buildAppPlugin(plugin: BetterAuthPlugin) { 16 | const auth = betterAuth({ 17 | database: mongodbAdapter(db), 18 | emailAndPassword: { 19 | enabled: true, 20 | }, 21 | plugins: [ 22 | plugin 23 | ], 24 | }); 25 | 26 | const app = getApp(auth); 27 | const req = supertest(app); 28 | return { app, req }; 29 | } 30 | 31 | describe("Test minimal config options calling the plugin", () => { 32 | const { req } = buildAppPlugin(credentials({ 33 | autoSignUp: true, 34 | callback(ctx, parsed) { 35 | return {}; 36 | }, 37 | })); 38 | 39 | test("Minimal config", async () => { 40 | // Sign up a new user 41 | const response = await req.post("/api/auth/sign-in/credentials") 42 | .set("Accept", "application/json") 43 | .send({ 44 | email: "config_email@example.com", 45 | password: "password" 46 | }) 47 | .expect(200); 48 | 49 | expect(response).toBeTruthy(); 50 | expect(response.body).toMatchObject({ 51 | user: { 52 | email: "config_email@example.com" 53 | } 54 | }); 55 | expect(response.body.user.name).toBeUndefined(); 56 | 57 | 58 | // Shouldn't be able to sign in using user created with email & password 59 | await req 60 | .post("/api/auth/sign-up/email") 61 | .set("Accept", "application/json") 62 | .send({ 63 | name: "Email Config User", 64 | email: "config_email2@example.com", 65 | password: "password" 66 | }) 67 | .expect(200); 68 | 69 | await req.post("/api/auth/sign-in/credentials") 70 | .set("Accept", "application/json") 71 | .send({ 72 | email: "config_email2@example.com", 73 | password: "password" 74 | }) 75 | .expect(401); 76 | }); 77 | }); 78 | 79 | describe("Test all config options calling the plugin", () => { 80 | const { req } = buildAppPlugin(credentials({ 81 | autoSignUp: true, 82 | inputSchema: z.object({ 83 | credential: z.string().min(1), 84 | password: z.string().min(1), 85 | }), 86 | linkAccountIfExisting: true, 87 | path: "/my-sign-in", 88 | providerId: "config", 89 | callback: (ctx, parsed) => { 90 | return { 91 | email: parsed.credential + "@example.com", 92 | 93 | onSignIn(userData, user, account) { 94 | if(!account) { 95 | if(parsed.password.length < 6) { 96 | return null; 97 | } 98 | } else { 99 | if(parsed.password !== account?.password) { 100 | return null; 101 | } 102 | } 103 | 104 | userData.name = user.name + ":" + (account?.scope || "?"); 105 | return userData; 106 | }, 107 | onSignUp(userData) { 108 | if(parsed.password.length < 6) { 109 | return null; 110 | } 111 | 112 | userData.name = parsed.credential; 113 | return userData; 114 | }, 115 | onLinkAccount(user) { 116 | return { 117 | scope: "test", 118 | password: parsed.password 119 | }; 120 | }, 121 | }; 122 | } 123 | })); 124 | 125 | testCases("Test cases config options", [ 126 | { 127 | status: 401, 128 | body: {credential: "config_email2", password: "passw"}, 129 | match: {} 130 | }, // Invalid Sign up 131 | { 132 | status: 200, 133 | body: {credential: "config_email2", password: "password"}, 134 | match: {name: "Email Config User:?"} 135 | }, // Sign up 136 | { 137 | status: 200, 138 | body: {credential: "config_email2", password: "password"}, 139 | match: {name: "Email Config User:?:test"} 140 | }, // Sign in 141 | { 142 | status: 401, 143 | body: {credential: "config_email2", password: "wrong-pass"}, 144 | match: {} 145 | }, // Mismatch 146 | 147 | { 148 | status: 401, 149 | body: {credential: "config_email3", password: "passw"}, 150 | match: {} 151 | }, // Invalid Sign up 152 | { 153 | status: 200, 154 | body: {credential: "config_email3", password: "password"}, 155 | match: {name: "config_email3"} 156 | }, // Sign up 157 | { 158 | status: 200, 159 | body: {credential: "config_email3", password: "password"}, 160 | match: {name: "config_email3:test"} 161 | }, // Sign in 162 | { 163 | status: 401, 164 | body: {credential: "config_email3", password: "wrong-pass"}, 165 | match: {} 166 | }, // Mismatch 167 | ], async ({status, body, match}) => { 168 | const response = await req.post("/api/auth/my-sign-in") 169 | .set("Accept", "application/json") 170 | .send(body) 171 | .expect(status); 172 | 173 | if(status >= 200 && status < 300) { 174 | const respBody = response.body; 175 | expect(respBody?.user).toBeTruthy(); 176 | 177 | expect(respBody.user).toMatchObject(match); 178 | } 179 | }); 180 | }); -------------------------------------------------------------------------------- /test/plugin/email-verification.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTestInstance } from "@better-auth-kit/tests"; 2 | import { beforeAll, describe, expect, test } from "vitest"; 3 | import { defaultBetterAuthOptions } from "../plugin.js"; 4 | import { credentials, credentialsClient } from "../../index.js"; 5 | 6 | import { betterAuth, User } from "better-auth"; 7 | import { bearer } from "better-auth/plugins"; 8 | 9 | describe("Test using the plugin with email verification ON", () => { 10 | 11 | let lastEmailSent: { user: User; url: string; token: string } | null = null; 12 | 13 | const _instance = getTestInstance( 14 | betterAuth({ 15 | ...defaultBetterAuthOptions, 16 | emailAndPassword: { 17 | enabled: true, 18 | requireEmailVerification: true 19 | }, 20 | emailVerification: { 21 | sendVerificationEmail: async ({user, url, token}: {user: User; url: string; token: string}) => { 22 | console.log("Sending verification email to:", user.email); 23 | // This is just a test, we don't actually send an email 24 | lastEmailSent = { 25 | user, 26 | url, 27 | token 28 | }; 29 | }, 30 | onEmailVerification: async (user: User, request?: Request) => { 31 | console.log("Email verification completed for user:", user.email); 32 | }, 33 | sendOnSignUp: true, 34 | autoSignInAfterVerification: true 35 | }, 36 | plugins: [ 37 | bearer(), 38 | credentials({ 39 | autoSignUp: true, 40 | providerId: "email", 41 | callback(ctx, parsed) { 42 | if (parsed.email !== parsed.password) { 43 | throw new Error("Authentication failed, please try again."); 44 | } 45 | 46 | return {}; 47 | } 48 | }), 49 | ] 50 | }), { clientOptions: { plugins: [credentialsClient()] } } 51 | ); 52 | 53 | let client: (Awaited)["client"]; 54 | 55 | beforeAll(async () => { 56 | let instance = await _instance; 57 | client = instance.client; 58 | }); 59 | 60 | test("Test new user", async () => { 61 | // Wrong password on new user 62 | { 63 | const {data, error} = await client.signIn.credentials({ 64 | email: "email_user1@example.com", 65 | password: "wrong-password" 66 | }); 67 | 68 | expect(error).toBeTruthy(); 69 | expect(data).toBeNull(); 70 | expect(error?.status).toBe(401); 71 | } 72 | 73 | // Sign up, should send verification email 74 | { 75 | const {data, error} = await client.signIn.credentials({ 76 | email: "email_user1@example.com", 77 | password: "email_user1@example.com" 78 | }); 79 | 80 | expect(error).toBeNull(); 81 | const user = data?.user; 82 | const token = data?.token; 83 | expect(user).toBeTruthy(); 84 | expect(user!.name).toBeUndefined(); 85 | expect(token).toBeNull(); // Should not be able to sign in yet because email verification is required 86 | expect(lastEmailSent).toBeTruthy(); // Should have sent a verification email 87 | } 88 | 89 | // Try to sign in but email verification is required 90 | { 91 | const {data, error} = await client.signIn.credentials({ 92 | email: "email_user1@example.com", 93 | password: "email_user1@example.com" 94 | }); 95 | 96 | expect(error).toBeTruthy(); 97 | expect(data).toBeNull(); 98 | expect(error?.status).toBe(403); 99 | expect(error?.code).toBe("EMAIL_NOT_VERIFIED"); 100 | } 101 | 102 | // Verify e-mail 103 | { 104 | const { data, error} = await client.verifyEmail({ 105 | query: { 106 | token: lastEmailSent!.token 107 | } 108 | }); 109 | 110 | expect(error).toBeNull(); 111 | expect(data).toBeTruthy(); 112 | expect(data!.status).toBe(true); 113 | } 114 | 115 | // Check if now is able to sign in 116 | { 117 | const {data, error} = await client.signIn.credentials({ 118 | email: "email_user1@example.com", 119 | password: "email_user1@example.com" 120 | }); 121 | 122 | expect(error).toBeNull(); 123 | const user = data?.user; 124 | const token = data?.token; 125 | expect(user).toBeTruthy(); 126 | expect(user!.name).toBeUndefined(); 127 | expect(user!.emailVerified).toBe(true); 128 | 129 | expect(token).toBeTruthy(); 130 | } 131 | }); 132 | }); -------------------------------------------------------------------------------- /test/plugin/link-account.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTestInstance } from "@better-auth-kit/tests"; 2 | import { beforeAll, describe, expect, test } from "vitest"; 3 | import { defaultBetterAuthOptions } from "../plugin.js"; 4 | import { credentials, credentialsClient } from "../../index.js"; 5 | 6 | import { APIError, EndpointContext } from "better-call"; 7 | import { betterAuth, User } from "better-auth"; 8 | import { bearer } from "better-auth/plugins"; 9 | 10 | describe("Test using the plugin with custom Account linking attributes", () => { 11 | const _instance = getTestInstance( 12 | betterAuth({ 13 | ...defaultBetterAuthOptions, 14 | plugins: [ 15 | bearer(), 16 | credentials({ 17 | autoSignUp: true, 18 | linkAccountIfExisting: true, 19 | providerId: "link", 20 | callback(ctx, parsed) { 21 | const registerFn = () => { 22 | if(parsed.password.length < 12) { 23 | throw new APIError("BAD_REQUEST", { code: "WEAK_PASSWORD", message: "Password must be at least 12 characters long." }); 24 | } 25 | }; 26 | 27 | return { 28 | onSignIn(userData, user, account) { 29 | if(!account) { 30 | // Account linking, first time sign in this provider on an existing user 31 | registerFn(); 32 | if(!user.image) { 33 | userData.image = "http://example.com/linked.png"; 34 | } 35 | return userData; 36 | } 37 | 38 | // Check password 39 | if(parsed.password !== account.password) { 40 | throw new Error("Password didn't match."); 41 | } 42 | return userData; 43 | }, 44 | onSignUp(userData) { 45 | // Account creation, sign up this provider and user 46 | registerFn(); 47 | return { 48 | ...userData, 49 | name: parsed.email.split("@")[0], 50 | image: "http://example.com/new.png", 51 | }; 52 | }, 53 | // This callback can't throw errors, because the user already was created 54 | onLinkAccount(user) { 55 | // THIS IS JUST FOR TESTING PURPOSES, REAL USE CASES SHOULD STORE HASHED PASSWORDS 56 | return { 57 | password: parsed.password, 58 | }; 59 | }, 60 | }; 61 | } 62 | }), 63 | ] 64 | }), { clientOptions: { plugins: [credentialsClient()] } } 65 | ); 66 | 67 | let client: (Awaited)["client"]; 68 | 69 | beforeAll(async () => { 70 | let instance = await _instance; 71 | client = instance.client; 72 | }); 73 | 74 | 75 | test("Test new user", async () => { 76 | // Weak password on new user 77 | { 78 | const {data, error} = await client.signIn.credentials({ 79 | email: "link_user1@example.com", 80 | password: "12345678" 81 | }); 82 | 83 | expect(error).toBeDefined(); 84 | expect(data).toBeNull(); 85 | expect(error?.status).toBe(400); 86 | expect(error?.code).toBe("WEAK_PASSWORD"); 87 | } 88 | 89 | // Sign up, then repeat for sign in 90 | for(let i = 0; i < 2; i++) { 91 | const {data, error} = await client.signIn.credentials({ 92 | email: "link_user1@example.com", 93 | password: "123456789012" 94 | }); 95 | 96 | expect(error).toBeNull(); 97 | const user = data?.user; 98 | expect(user).toBeDefined(); 99 | expect(user!.email).toBe("link_user1@example.com"); 100 | expect(user!.name).toBe("link_user1"); 101 | expect(user!.image).toBe("http://example.com/new.png"); 102 | } 103 | 104 | // Wrong password on sign in 105 | { 106 | const {data, error} = await client.signIn.credentials({ 107 | email: "link_user1@example.com", 108 | password: "abcdefgh" 109 | }); 110 | 111 | expect(error).toBeDefined(); 112 | expect(data).toBeNull(); 113 | expect(error?.status).toBe(401); 114 | } 115 | }); 116 | 117 | test("Test link Account existing user", async () => { 118 | // new account default credentials provider 119 | { 120 | const {data,error} = await client.signUp.email({ 121 | name: "Link User 2", 122 | email: "link_user2@example.com", 123 | password: "abcdefgh" 124 | }); 125 | 126 | expect(error).toBeNull(); 127 | expect(data).toBeDefined(); 128 | } 129 | 130 | // Weak password on link user 131 | { 132 | const {data, error} = await client.signIn.credentials({ 133 | email: "link_user2@example.com", 134 | password: "abcdefgh" 135 | }); 136 | 137 | expect(error).toBeDefined(); 138 | expect(data).toBeNull(); 139 | expect(error?.status).toBe(400); 140 | expect(error?.code).toBe("WEAK_PASSWORD"); 141 | } 142 | 143 | // Link account, then repeat for sign in 144 | for(let i = 0; i < 2; i++) { 145 | const {data, error} = await client.signIn.credentials({ 146 | email: "link_user2@example.com", 147 | password: "123456789012" 148 | }); 149 | 150 | expect(error).toBeNull(); 151 | const user = data?.user; 152 | expect(user).toBeDefined(); 153 | expect(user!.email).toBe("link_user2@example.com"); 154 | expect(user!.name).toBe("Link User 2"); 155 | expect(user!.image).toBe("http://example.com/linked.png"); 156 | } 157 | 158 | 159 | // Wrong password on sign in 160 | { 161 | const {data, error} = await client.signIn.credentials({ 162 | email: "link_user2@example.com", 163 | password: "abcdefgh" 164 | }); 165 | 166 | expect(error).toBeDefined(); 167 | expect(data).toBeNull(); 168 | expect(error?.status).toBe(401); 169 | } 170 | }); 171 | }); -------------------------------------------------------------------------------- /test/plugin/no-signup.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTestInstance } from "@better-auth-kit/tests"; 2 | import { beforeAll, describe, expect, test } from "vitest"; 3 | import { defaultBetterAuthOptions } from "../plugin.js"; 4 | import { credentials, credentialsClient } from "../../index.js"; 5 | import { betterAuth, User } from "better-auth"; 6 | import { bearer } from "better-auth/plugins"; 7 | import { testCases } from "../test-helpers.js"; 8 | 9 | describe("Test with sign-up disabled", () => { 10 | 11 | let _instance = getTestInstance( 12 | betterAuth({ 13 | ...defaultBetterAuthOptions, 14 | plugins: [ 15 | bearer(), 16 | credentials({ 17 | autoSignUp: false, // Sign-up is disabled 18 | providerId: "disabled", 19 | callback(ctx, parsed) { 20 | return { 21 | onSignIn(userData, user, account) { 22 | if(!account) return null; 23 | 24 | if (parsed.password !== account.password) { 25 | console.error("Authentication failed mismatch for email and password"); 26 | return null; 27 | } 28 | 29 | return userData; 30 | }, 31 | }; 32 | } 33 | }), 34 | ] 35 | }), 36 | { 37 | clientOptions: { 38 | plugins: [credentialsClient()], 39 | }, 40 | }, 41 | ); 42 | let instance: Awaited; 43 | 44 | beforeAll(async () => { 45 | instance = await _instance; 46 | const { client } = instance; 47 | 48 | const users = new Array(10).fill({}).map((_, index) => ({ 49 | name: `Disabled User ${index + 1}`, 50 | email: `disabled${index + 1}@example.com`, 51 | password: `password${index + 1}`, 52 | })); 53 | // 1. Sign up test users using email and password directly in the database 54 | for(const user of users) { 55 | const userCreated = await instance.db.create({ 56 | model: "user", 57 | data: { 58 | name: user.name, 59 | email: user.email, 60 | emailVerified: false 61 | } 62 | }); 63 | 64 | await instance.db.create({ 65 | model: "account", 66 | data: { 67 | providerId: "disabled", 68 | accountId: user.email, 69 | userId: userCreated.id, 70 | password: user.password, // Store the password as plain text for testing purposes 71 | } 72 | }); 73 | } 74 | 75 | //2. Sign up a user with email and password 76 | const {data,error} = await client.signUp.email({ 77 | name: "Disabled User Existing", 78 | email: "disabled_existing@example.com", 79 | password: "abcdefgh" 80 | }); 81 | }); 82 | 83 | testCases("Test cases sign-up disabled", [ 84 | { 85 | status: 401, 86 | body: {email: "disabled0@example.com", password: "password0"}, 87 | match: {} 88 | }, 89 | { 90 | status: 401, 91 | body: {email: "disabled1@example.com", password: "password?"}, 92 | match: {} 93 | }, 94 | { 95 | status: 200, 96 | body: {email: "disabled1@example.com", password: "password1"}, 97 | match: { name: "Disabled User 1" } 98 | }, 99 | { 100 | status: 200, 101 | body: {email: "disabled2@example.com", password: "password2"}, 102 | match: { name: "Disabled User 2" } 103 | }, 104 | 105 | { 106 | status: 401, 107 | body: {email: "disabled_existing@example.com", password: "password0"}, 108 | match: {} 109 | }, 110 | { 111 | status: 401, 112 | body: {email: "disabled_existing@example.com", password: "abcdefgh"}, 113 | match: {} 114 | }, 115 | ], async ({status, body, match}) => { 116 | const { client } = instance; 117 | const { data, error } = await client.signIn.credentials(body); 118 | 119 | if(status >= 200 && status < 300) { 120 | expect(data).toBeTruthy(); 121 | expect(data?.user).toBeTruthy(); 122 | expect(data?.user).toMatchObject(match); 123 | expect(error).toBeNull(); 124 | } else { 125 | expect(error).toBeTruthy(); 126 | expect(error?.status).toBe(status); 127 | expect(data).toBeNull(); 128 | } 129 | }); 130 | }); -------------------------------------------------------------------------------- /test/plugin/zod3.node.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTestInstance } from "@better-auth-kit/tests"; 2 | import { beforeAll, describe, expect, test } from "vitest"; 3 | import { defaultBetterAuthOptions } from "../plugin.js"; 4 | import { credentials, credentialsClient } from "../../index.js"; 5 | import { betterAuth, User } from "better-auth"; 6 | import { bearer } from "better-auth/plugins"; 7 | import { testCases } from "../test-helpers.js"; 8 | import * as z3 from "zod/v3"; 9 | 10 | describe("Should still work with zod v3 also", () => { 11 | 12 | const _instance = getTestInstance( 13 | betterAuth({ 14 | ...defaultBetterAuthOptions, 15 | emailAndPassword: { 16 | enabled: true 17 | }, 18 | plugins: [ 19 | bearer(), 20 | credentials({ 21 | autoSignUp: true, 22 | providerId: "zodv3", 23 | inputSchema: z3.object({ 24 | email: z3.string().email().min(1), 25 | name: z3.string().min(1).optional(), 26 | senha: z3.string().min(8), 27 | rememberMe: z3.boolean().optional(), 28 | }), 29 | callback(ctx, parsed) { 30 | return { 31 | email: parsed.email, 32 | name: parsed.name 33 | }; 34 | } 35 | }), 36 | ] 37 | }), { clientOptions: { plugins: [credentialsClient()] } } 38 | ); 39 | 40 | let client: (Awaited)["client"]; 41 | 42 | beforeAll(async () => { 43 | let instance = await _instance; 44 | client = instance.client; 45 | }); 46 | 47 | testCases("Test cases, comparison of behaviour, signUp cases", [ 48 | { // Fail because no body 49 | body: { }, 50 | match: {} 51 | }, 52 | { // Fail because missing field 53 | body: {name: "test1", email: "zodtest1"}, 54 | match: {} 55 | }, 56 | { // Fail because password too short 57 | body: {name: "test1", email: "zodtest1", senha: "passw"}, 58 | match: {} 59 | }, 60 | { // Missing name, works??? 61 | body: {email: "zodtest-no-name", senha: "password1"}, 62 | match: { 63 | name: undefined, 64 | email: "zodtest-no-name", 65 | emailVerified: false 66 | } 67 | }, 68 | { 69 | body: {name: "test1", email: "zodtest1", senha: "password1"}, 70 | match: { 71 | name: "test1", 72 | email: "zodtest1", 73 | emailVerified: false 74 | } 75 | } 76 | ], async ({body, match}) => { 77 | const credResult = await client.signIn.credentials({...body, email: body.email+"@zod.example.com"} as any); 78 | 79 | console.log("Credentials result:", credResult); 80 | 81 | let {data, error} = credResult; 82 | 83 | if(!error) { 84 | expect(data).toBeTruthy(); 85 | expect(data?.user).toBeTruthy(); 86 | for(const key of Object.keys(match)) { 87 | if(key === "email") { 88 | expect(data?.user.email.startsWith(match.email!)).toBe(true); 89 | } else { 90 | expect((data as any)?.user[key]).toBe((match as any)[key]); 91 | } 92 | } 93 | expect(error).toBeNull(); 94 | } else { 95 | expect(error).toBeTruthy(); 96 | expect(data).toBeNull(); 97 | expect(match).toEqual({}); // in case of error, match should be empty 98 | } 99 | 100 | }); 101 | }); -------------------------------------------------------------------------------- /test/setupEach.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll } from "vitest"; 2 | import { setup, teardown } from "vitest-mongodb"; 3 | 4 | if(!globalThis.__MONGO_URI__) { 5 | await setup(); 6 | } 7 | process.env.DB_URL_AUTH = globalThis.__MONGO_URI__; 8 | 9 | afterAll(async () => { 10 | await teardown(); 11 | }); -------------------------------------------------------------------------------- /test/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | 3 | export function testCases(testDescription: string, cases: T[], testFunction: (testCase: T) => Promise | void) { 4 | for (const [i, testCase] of cases.entries()) { 5 | test(testDescription+" (case: "+i+")", async () => { 6 | try { 7 | await testFunction(testCase); 8 | } catch (error) { 9 | console.error(`Error occurred while testing case ${i}: ${JSON.stringify(testCase)}`, error); 10 | throw error; // Re-throw the error to fail the test 11 | } 12 | }); 13 | } 14 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": ["es5","es6","dom"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "NodeNext", /* Specify what module code is generated. */ 29 | "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | 87 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 88 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 89 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 90 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 91 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 92 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 93 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 94 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 95 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 96 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 97 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 98 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 99 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 100 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 101 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 102 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 103 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 104 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 105 | 106 | /* Completeness */ 107 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 108 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 109 | }, 110 | "ts-node": { 111 | "esm": true, 112 | "experimentalSpecifierResolution": "explicit", 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | import { defineConfig } from "vitest/config"; 4 | 5 | export default defineConfig({ 6 | test: { 7 | include: ["test\/**\/*.{test,spec}.{ts,js}"], 8 | env: { 9 | // Define environment variables for tests 10 | NODE_ENV: "test" 11 | }, 12 | silent: "passed-only", 13 | setupFiles: ["test\/setupEach.ts"], 14 | projects: [ 15 | { 16 | extends: true, 17 | test: { 18 | exclude: ["**\/*.browser.*"], 19 | name: "node", 20 | environment: "node" 21 | } 22 | }, 23 | { 24 | extends: true, 25 | test: { 26 | exclude: ["**\/*.node.*"], 27 | name: "browser", 28 | environment: "happy-dom" 29 | } 30 | } 31 | ] 32 | }, 33 | }); --------------------------------------------------------------------------------