Merge branch 'main' into v1.2

This commit is contained in:
Bereket Engida
2025-02-14 15:17:41 +03:00
32 changed files with 468 additions and 98 deletions

View File

@@ -46,3 +46,5 @@ export const {
useListOrganizations, useListOrganizations,
useActiveOrganization, useActiveOrganization,
} = client; } = client;
client.$store.listen("$sessionSignal", async () => {});

View File

@@ -173,27 +173,33 @@ export const socialProviders = {
Icon: (props: SVGProps<any>) => ( Icon: (props: SVGProps<any>) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="1em" width="0.98em"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 256 262"
{...props}
> >
<path <path
fill="currentColor" fill="#4285F4"
d="M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z" d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
></path>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
></path>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
></path>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
></path> ></path>
</svg> </svg>
), ),
stringIcon: `<svg stringIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="0.98em" height="1em" viewBox="0 0 256 262">
xmlns="http://www.w3.org/2000/svg" <path fill="#4285F4" d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"></path>
width="1em" <path fill="#34A853" d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"></path>
height="1em" <path fill="#FBBC05" d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"></path>
viewBox="0 0 24 24" <path fill="#EB4335" d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"></path>
>
<path
fill="currentColor"
d="M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z"
></path>
</svg>`, </svg>`,
}, },
linkedin: { linkedin: {

View File

@@ -223,6 +223,24 @@ const { data, error } = await authClient.resetPassword({
}); });
``` ```
### Update password
<Callout type="warn">
This only works on server-side, and the following code may change over time.
</Callout>
To set a password, you must hash it first:
```ts
const ctx = await auth.$context;
const hash = await ctx.password.hash("your-new-password");
```
Then, to set the password:
```ts
await ctx.internalAdapter.updatePassword("userId", hash) //(you can also use your orm directly)
```
### Configuration ### Configuration
**Password** **Password**

View File

@@ -73,7 +73,7 @@ The `getSession` function retrieves the current active session.
```ts client="client.ts" ```ts client="client.ts"
import { authClient } from "@/lib/client" import { authClient } from "@/lib/client"
const session = await authClient.getSession() const { data: session } = await authClient.getSession()
``` ```
To learn how to customize the session response check the [Customizing Session Response](#customizing-session-response) section. To learn how to customize the session response check the [Customizing Session Response](#customizing-session-response) section.
@@ -85,7 +85,7 @@ The `useSession` action provides a reactive way to access the current session.
```ts client="client.ts" ```ts client="client.ts"
import { authClient } from "@/lib/client" import { authClient } from "@/lib/client"
const session = await authClient.useSession() const { data: session } = authClient.useSession()
``` ```
### List Sessions ### List Sessions

View File

@@ -37,7 +37,7 @@ import { cors } from "hono/cors";
const app = new Hono(); const app = new Hono();
app.use( app.use(
"/api/auth/**", // or replace with "*" to enable cors for all routes "/api/auth/*", // or replace with "*" to enable cors for all routes
cors({ cors({
origin: "http://localhost:3001", // replace with your origin origin: "http://localhost:3001", // replace with your origin
allowHeaders: ["Content-Type", "Authorization"], allowHeaders: ["Content-Type", "Authorization"],
@@ -82,7 +82,7 @@ app.use("*", async (c, next) => {
return next(); return next();
}); });
app.on(["POST", "GET"], "/api/auth/**", (c) => { app.on(["POST", "GET"], "/api/auth/*", (c) => {
return auth.handler(c.req.raw); return auth.handler(c.req.raw);
}); });

View File

@@ -7,7 +7,7 @@ Better Auth is a framework-agnostic authentication and authorization framework f
## Why Better Auth? ## Why Better Auth?
*Authentication in the TypeScript ecosystem has long been a half-solved problem. Other open-source libraries often require a lot of additional code for anything beyond basic authentication features.Rather than just pushing third-party services as the solution, I believe we can do better as a community—hence, Better Auth* *Authentication in the TypeScript ecosystem has long been a half-solved problem. Other open-source libraries often require a lot of additional code for anything beyond basic authentication features. Rather than just pushing third-party services as the solution, I believe we can do better as a community—hence, Better Auth*
## Features ## Features

View File

@@ -53,7 +53,7 @@ The Email OTP plugin allows user to sign-in, verify their email, or reset their
First, send an OTP to the user's email address. First, send an OTP to the user's email address.
```ts title="example.ts" ```ts title="example.ts"
await authClient.emailOtp.sendVerificationOtp({ const { data, error } = await authClient.emailOtp.sendVerificationOtp({
email: "user-email@email.com", email: "user-email@email.com",
type: "sign-in" // or "email-verification", "forget-password" type: "sign-in" // or "email-verification", "forget-password"
}) })
@@ -64,7 +64,7 @@ await authClient.emailOtp.sendVerificationOtp({
Once the user provides the OTP, you can sign in the user using the `signIn.emailOTP()` method. Once the user provides the OTP, you can sign in the user using the `signIn.emailOTP()` method.
```ts title="example.ts" ```ts title="example.ts"
const user = await authClient.signIn.emailOtp({ const { data, error } = await authClient.signIn.emailOtp({
email: "user-email@email.com", email: "user-email@email.com",
otp: "123456" otp: "123456"
}) })
@@ -77,7 +77,7 @@ If the user is not registered, they'll be automatically registered. If you want
To verify the user's email address, use the `verifyEmail()` method. To verify the user's email address, use the `verifyEmail()` method.
```ts title="example.ts" ```ts title="example.ts"
const user = await authClient.emailOtp.verifyEmail({ const { data, error } = await authClient.emailOtp.verifyEmail({
email: "user-email@email.com", email: "user-email@email.com",
otp: "123456" otp: "123456"
}) })
@@ -88,7 +88,7 @@ const user = await authClient.emailOtp.verifyEmail({
To reset the user's password, use the `resetPassword()` method. To reset the user's password, use the `resetPassword()` method.
```ts title="example.ts" ```ts title="example.ts"
await authClient.emailOtp.resetPassword({ const { data, error } = await authClient.emailOtp.resetPassword({
email: "user-email@email.com", email: "user-email@email.com",
otp: "123456", otp: "123456",
password: "password" password: "password"
@@ -146,4 +146,4 @@ export const auth = betterAuth({
- `sendVerificationOnSignUp`: A boolean value that determines whether to send the OTP when a user signs up. Defaults to `false`. - `sendVerificationOnSignUp`: A boolean value that determines whether to send the OTP when a user signs up. Defaults to `false`.
- `disableSignUp`: A boolean value that determines whether to prevent automatic sign-up when the user is not registered. Defaults to `false`. - `disableSignUp`: A boolean value that determines whether to prevent automatic sign-up when the user is not registered. Defaults to `false`.

View File

@@ -70,6 +70,17 @@ const response = await authClient.signIn.oauth2({
}); });
``` ```
### Linking OAuth Accounts
To link an OAuth account to an existing user:
```ts title="link-account.ts"
const response = await authClient.oauth2.link({
providerId: "provider-id",
callbackURL: "/dashboard" // the path to redirect to after the account is linked
});
```
### Handle OAuth Callback ### Handle OAuth Callback
The plugin mounts a route to handle the OAuth callback `/oauth2/callback/:providerId`. This means by default `${baseURL}/api/auth/oauth2/callback/:providerId` will be used as the callback URL. Make sure your OAuth provider is configured to use this URL. The plugin mounts a route to handle the OAuth callback `/oauth2/callback/:providerId`. This means by default `${baseURL}/api/auth/oauth2/callback/:providerId` will be used as the callback URL. Make sure your OAuth provider is configured to use this URL.

View File

@@ -3,9 +3,10 @@ title: Magic link
description: Magic link plugin description: Magic link plugin
--- ---
Magic link or email link is a way to authenticate users without a password. When a user enters their email, a link is sent to their email. When the user clicks on the link, they are authenticated. Magic link or email link is a way to authenticate users without a password. When a user enters their email, a link is sent to their email. When the user clicks on the link, they are authenticated.
## Installation ## Installation
<Steps> <Steps>
<Step> <Step>
### Add the server Plugin ### Add the server Plugin
@@ -43,18 +44,19 @@ Magic link or email link is a way to authenticate users without a password. When
}); });
``` ```
</Step> </Step>
</Steps> </Steps>
## Usage ## Usage
### Sign In with Magic Link ### Sign In with Magic Link
To sign in with a magic link, you need to call `signIn.magicLink` with the user's email address. The `sendMagicLink` function is called to send the magic link to the user's email. To sign in with a magic link, you need to call `signIn.magicLink` with the user's email address. The `sendMagicLink` function is called to send the magic link to the user's email.
```ts title="magic-link.ts" ```ts title="magic-link.ts"
const { data, error } = await authClient.signIn.magicLink({ const { data, error } = await authClient.signIn.magicLink({
email: "user@email.com", email: "user@email.com",
callbackURL: "/dashboard" //redirect after successful login (optional) callbackURL: "/dashboard", //redirect after successful login (optional)
}); });
``` ```
@@ -65,16 +67,16 @@ If the user has not signed up, unless `disableSignUp` is set to `true`, the user
When you send the URL generated by the `sendMagicLink` function to a user, clicking the link will authenticate them and redirect them to the `callbackURL` specified in the `signIn.magicLink` function. If an error occurs, the user will be redirected to the `callbackURL` with an error query parameter. When you send the URL generated by the `sendMagicLink` function to a user, clicking the link will authenticate them and redirect them to the `callbackURL` specified in the `signIn.magicLink` function. If an error occurs, the user will be redirected to the `callbackURL` with an error query parameter.
<Callout type="warn"> <Callout type="warn">
If no `callbackURL` is provided, the user will be redirected to the root URL. If no `callbackURL` is provided, the user will be redirected to the root URL.
</Callout> </Callout>
If you want to handle the verification manually, (e.g, if you send the user a different url), you can use the `verify` function. If you want to handle the verification manually, (e.g, if you send the user a different url), you can use the `verify` function.
```ts title="magic-link.ts" ```ts title="magic-link.ts"
const { data, error } = await authClient.magicLink.verify({ const { data, error } = await authClient.magicLink.verify({
query: { query: {
token token,
} },
}); });
``` ```
@@ -91,3 +93,13 @@ and a `request` object as the second parameter.
**expiresIn**: specifies the time in seconds after which the magic link will expire. The default value is `300` seconds (5 minutes). **expiresIn**: specifies the time in seconds after which the magic link will expire. The default value is `300` seconds (5 minutes).
**disableSignUp**: If set to `true`, the user will not be able to sign up using the magic link. The default value is `false`. **disableSignUp**: If set to `true`, the user will not be able to sign up using the magic link. The default value is `false`.
**generateToken**: The `generateToken` function is called to generate a token which is used to uniquely identify the user. The default value is a random string. There is one parameter:
- `email`: The email address of the user.
<Callout type="warn">
When using `generateToken`, ensure that the returned string is hard to guess
because it is used to verify who someone actually is in a confidential way. By
default, we return a long and cryptographically secure string.
</Callout>

View File

@@ -544,6 +544,16 @@ auth.api.addMember({
}) })
``` ```
### Leave Organization
To leave organization you can use `organization.leave` function. This function will remove the current user from the organization.
```ts title="auth-client.ts"
await authClient.organization.leave({
organizationId: "organization-id"
})
```
## Access Control ## Access Control
The organization plugin providers a very flexible access control system. You can control the access of the user based on the role they have in the organization. You can define your own set of permissions based on the role of the user. The organization plugin providers a very flexible access control system. You can control the access of the user based on the role they have in the organization. You can define your own set of permissions based on the role of the user.

View File

@@ -85,7 +85,6 @@ const authClient = createAuthClient({
passkeyClient(), // [!code highlight] passkeyClient(), // [!code highlight]
], // [!code highlight] ], // [!code highlight]
}); });
// ---cut---
const data = await authClient.passkey.addPasskey(); const data = await authClient.passkey.addPasskey();
``` ```
@@ -111,7 +110,6 @@ const authClient = createAuthClient({
passkeyClient(), // [!code highlight] passkeyClient(), // [!code highlight]
], // [!code highlight] ], // [!code highlight]
}); });
// ---cut---
const data = await authClient.signIn.passkey(); const data = await authClient.signIn.passkey();
``` ```

View File

@@ -141,6 +141,35 @@ describe("Origin Check", async (it) => {
expect(res.error?.message).toBe("Invalid redirectURL"); expect(res.error?.message).toBe("Invalid redirectURL");
}); });
it("should work with list of trusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://trusted.com",
},
},
});
const res = await client.forgetPassword({
email: testUser.email,
redirectTo: "http://localhost:5000/reset-password",
});
expect(res.data?.status).toBeTruthy();
const res2 = await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
// @ts-expect-error - query is not defined in the type
query: {
currentURL: "http://localhost:5000",
},
},
});
expect(res2.data?.user).toBeDefined();
});
it("should work with wildcard trusted origins", async (ctx) => { it("should work with wildcard trusted origins", async (ctx) => {
const client = createAuthClient({ const client = createAuthClient({
baseURL: "https://sub-domain.my-site.com", baseURL: "https://sub-domain.my-site.com",

View File

@@ -9,7 +9,7 @@ import type { GenericEndpointContext } from "../../types";
* trustedOrigins. * trustedOrigins.
*/ */
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
if (ctx.request?.method !== "POST") { if (ctx.request?.method !== "POST" || !ctx.request) {
return; return;
} }
const { body, query, context } = ctx; const { body, query, context } = ctx;
@@ -19,7 +19,12 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
const redirectURL = body?.redirectTo; const redirectURL = body?.redirectTo;
const errorCallbackURL = body?.errorCallbackURL; const errorCallbackURL = body?.errorCallbackURL;
const newUserCallbackURL = body?.newUserCallbackURL; const newUserCallbackURL = body?.newUserCallbackURL;
const trustedOrigins = context.trustedOrigins; const trustedOrigins: string[] = Array.isArray(context.options.trustedOrigins)
? context.trustedOrigins
: [
...context.trustedOrigins,
...(context.options.trustedOrigins?.(ctx.request) || []),
];
const usesCookies = ctx.headers?.has("cookie"); const usesCookies = ctx.headers?.has("cookie");
const matchesPattern = (url: string, pattern: string): boolean => { const matchesPattern = (url: string, pattern: string): boolean => {
@@ -66,9 +71,19 @@ export const originCheck = (
getValue: (ctx: GenericEndpointContext) => string, getValue: (ctx: GenericEndpointContext) => string,
) => ) =>
createAuthMiddleware(async (ctx) => { createAuthMiddleware(async (ctx) => {
if (!ctx.request) {
return;
}
const { context } = ctx; const { context } = ctx;
const callbackURL = getValue(ctx); const callbackURL = getValue(ctx);
const trustedOrigins = context.trustedOrigins; const trustedOrigins: string[] = Array.isArray(
context.options.trustedOrigins,
)
? context.trustedOrigins
: [
...context.trustedOrigins,
...(context.options.trustedOrigins?.(ctx.request) || []),
];
const matchesPattern = (url: string, pattern: string): boolean => { const matchesPattern = (url: string, pattern: string): boolean => {
if (url.startsWith("/")) { if (url.startsWith("/")) {

View File

@@ -35,7 +35,7 @@ function getRetryAfter(lastRequest: number, window: number) {
} }
function createDBStorage(ctx: AuthContext, modelName?: string) { function createDBStorage(ctx: AuthContext, modelName?: string) {
const model = "rateLimit"; const model = ctx.options.rateLimit?.modelName || "rateLimit";
const db = ctx.adapter; const db = ctx.adapter;
return { return {
get: async (key: string) => { get: async (key: string) => {

View File

@@ -40,7 +40,11 @@ export const betterAuth = <O extends BetterAuthOptions>(options: O) => {
ctx.baseURL = baseURL; ctx.baseURL = baseURL;
} }
ctx.trustedOrigins = [ ctx.trustedOrigins = [
...(options.trustedOrigins || []), ...(options.trustedOrigins
? Array.isArray(options.trustedOrigins)
? options.trustedOrigins
: options.trustedOrigins(request)
: []),
ctx.baseURL, ctx.baseURL,
url.origin, url.origin,
]; ];

View File

@@ -94,8 +94,8 @@ export type InferRoute<API, COpts extends ClientOptions> = API extends Record<
? C extends InputContext<any, any> ? C extends InputContext<any, any>
? < ? <
FetchOptions extends BetterFetchOption< FetchOptions extends BetterFetchOption<
C["body"] & Record<string, any>, Partial<C["body"]> & Record<string, any>,
C["query"] & Record<string, any>, Partial<C["query"]> & Record<string, any>,
C["params"] C["params"]
>, >,
>( >(

View File

@@ -100,8 +100,4 @@ export function createAuthClient<Option extends ClientOptions>(
}; };
} }
export type * from "@better-fetch/fetch";
//@ts-expect-error
export type * from "zod";
export { useStore }; export { useStore };

View File

@@ -24,13 +24,16 @@ export function getWithHooks(
for (const hook of hooks || []) { for (const hook of hooks || []) {
const toRun = hook[model]?.create?.before; const toRun = hook[model]?.create?.before;
if (toRun) { if (toRun) {
const result = await toRun(data as any); const result = await toRun(actualData as any);
if (result === false) { if (result === false) {
return null; return null;
} }
const isObject = typeof result === "object" && "data" in result; const isObject = typeof result === "object" && "data" in result;
if (isObject) { if (isObject) {
actualData = result.data as T; actualData = {
...actualData,
...result.data,
};
} }
} }
} }

View File

@@ -250,7 +250,7 @@ function getTrustedOrigins(options: BetterAuthOptions) {
return []; return [];
} }
const trustedOrigins = [new URL(baseURL).origin]; const trustedOrigins = [new URL(baseURL).origin];
if (options.trustedOrigins) { if (options.trustedOrigins && Array.isArray(options.trustedOrigins)) {
trustedOrigins.push(...options.trustedOrigins); trustedOrigins.push(...options.trustedOrigins);
} }
const envTrustedOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS; const envTrustedOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS;

View File

@@ -138,7 +138,8 @@ export async function handleOAuthUserInfo(
c.request, c.request,
); );
} }
} catch (e) { } catch (e: any) {
logger.error(e);
if (e instanceof APIError) { if (e instanceof APIError) {
return { return {
error: e.message, error: e.message,

View File

@@ -120,7 +120,6 @@ export const admin = <O extends AdminOptions>(options?: O) => {
return { return {
data: { data: {
role: options?.defaultRole ?? "user", role: options?.defaultRole ?? "user",
...user,
}, },
}; };
}, },
@@ -896,6 +895,29 @@ export const admin = <O extends AdminOptions>(options?: O) => {
}); });
}, },
), ),
setUserPassword: createAuthEndpoint(
"/admin/set-user-password",
{
method: "POST",
body: z.object({
newPassword: z.string(),
userId: z.string(),
}),
use: [adminMiddleware],
},
async (ctx) => {
const hashedPassword = await ctx.context.password.hash(
ctx.body.newPassword,
);
await ctx.context.internalAdapter.updatePassword(
ctx.body.userId,
hashedPassword,
);
return ctx.json({
status: true,
});
},
),
}, },
$ERROR_CODES: ERROR_CODES, $ERROR_CODES: ERROR_CODES,
schema: mergeSchema(schema, opts.schema), schema: mergeSchema(schema, opts.schema),

View File

@@ -1,7 +1,7 @@
import { betterFetch } from "@better-fetch/fetch"; import { betterFetch } from "@better-fetch/fetch";
import { APIError } from "better-call"; import { APIError } from "better-call";
import { z } from "zod"; import { z } from "zod";
import { createAuthEndpoint } from "../../api"; import { createAuthEndpoint, sessionMiddleware } from "../../api";
import { setSessionCookie } from "../../cookies"; import { setSessionCookie } from "../../cookies";
import { import {
createAuthorizationURL, createAuthorizationURL,
@@ -13,6 +13,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account";
import { generateState, parseState } from "../../oauth2/state"; import { generateState, parseState } from "../../oauth2/state";
import type { BetterAuthPlugin, User } from "../../types"; import type { BetterAuthPlugin, User } from "../../types";
import { decodeJwt } from "jose"; import { decodeJwt } from "jose";
import { BASE_ERROR_CODES } from "../../error/codes";
/** /**
* Configuration interface for generic OAuth providers. * Configuration interface for generic OAuth providers.
@@ -475,7 +476,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
let tokens: OAuth2Tokens | undefined = undefined; let tokens: OAuth2Tokens | undefined = undefined;
const parsedState = await parseState(ctx); const parsedState = await parseState(ctx);
const { callbackURL, codeVerifier, errorURL, newUserURL } = const { callbackURL, codeVerifier, errorURL, newUserURL, link } =
parsedState; parsedState;
const code = ctx.query.code; const code = ctx.query.code;
@@ -544,6 +545,28 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
? await provider.mapProfileToUser(userInfo) ? await provider.mapProfileToUser(userInfo)
: null; : null;
if (link) {
if (link.email !== userInfo.email.toLowerCase()) {
return redirectOnError("email_doesn't_match");
}
const newAccount = await ctx.context.internalAdapter.createAccount({
userId: link.userId,
providerId: provider.providerId,
accountId: userInfo.id,
});
if (!newAccount) {
return redirectOnError("unable_to_link_account");
}
let toRedirectTo: string;
try {
const url = callbackURL;
toRedirectTo = url.toString();
} catch {
toRedirectTo = callbackURL;
}
throw ctx.redirect(toRedirectTo);
}
const result = await handleOAuthUserInfo(ctx, { const result = await handleOAuthUserInfo(ctx, {
userInfo: { userInfo: {
...userInfo, ...userInfo,
@@ -586,6 +609,102 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
throw ctx.redirect(toRedirectTo); throw ctx.redirect(toRedirectTo);
}, },
), ),
oAuth2LinkAccount: createAuthEndpoint(
"/oauth2/link",
{
method: "POST",
body: z.object({
providerId: z.string(),
callbackURL: z.string(),
}),
use: [sessionMiddleware],
},
async (c) => {
const session = c.context.session;
const account = await c.context.internalAdapter.findAccounts(
session.user.id,
);
const existingAccount = account.find(
(a) => a.providerId === c.body.providerId,
);
if (existingAccount) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.SOCIAL_ACCOUNT_ALREADY_LINKED,
});
}
const provider = options.config.find(
(p) => p.providerId === c.body.providerId,
);
if (!provider) {
throw new APIError("NOT_FOUND", {
message: BASE_ERROR_CODES.PROVIDER_NOT_FOUND,
});
}
const {
providerId,
clientId,
clientSecret,
redirectURI,
authorizationUrl,
discoveryUrl,
pkce,
scopes,
} = provider;
let finalAuthUrl = authorizationUrl;
if (!finalAuthUrl) {
if (!discoveryUrl) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION,
});
}
const discovery = await betterFetch<{
authorization_endpoint: string;
token_endpoint: string;
}>(discoveryUrl, {
onError(context) {
c.context.logger.error(context.error.message, context.error, {
discoveryUrl,
});
},
});
if (discovery.data) {
finalAuthUrl = discovery.data.authorization_endpoint;
}
}
if (!finalAuthUrl) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION,
});
}
const state = await generateState(c, {
userId: session.user.id,
email: session.user.email,
});
const url = await createAuthorizationURL({
id: providerId,
options: {
clientId,
clientSecret,
redirectURI:
redirectURI || `${c.context.baseURL}/oauth2/callback`,
},
authorizationEndpoint: finalAuthUrl,
state: state.state,
codeVerifier: pkce ? state.codeVerifier : undefined,
scopes: scopes || [],
redirectURI: `${c.context.baseURL}/oauth2/callback/${providerId}`,
});
return c.json({
url: url.toString(),
redirect: true,
});
},
),
}, },
$ERROR_CODES: ERROR_CODES, $ERROR_CODES: ERROR_CODES,
} satisfies BetterAuthPlugin; } satisfies BetterAuthPlugin;

View File

@@ -42,6 +42,10 @@ interface MagicLinkOptions {
window: number; window: number;
max: number; max: number;
}; };
/**
* Custom function to generate a token
*/
generateToken?: (email: string) => Promise<string> | string;
} }
export const magicLink = (options: MagicLinkOptions) => { export const magicLink = (options: MagicLinkOptions) => {
@@ -108,7 +112,9 @@ export const magicLink = (options: MagicLinkOptions) => {
} }
} }
const verificationToken = generateRandomString(32, "a-z", "A-Z"); const verificationToken = options?.generateToken
? await options.generateToken(email)
: generateRandomString(32, "a-z", "A-Z");
await ctx.context.internalAdapter.createVerificationValue({ await ctx.context.internalAdapter.createVerificationValue({
identifier: verificationToken, identifier: verificationToken,
value: JSON.stringify({ email, name: ctx.body.name }), value: JSON.stringify({ email, name: ctx.body.name }),

View File

@@ -133,6 +133,36 @@ describe("magic link", async () => {
emailVerified: true, emailVerified: true,
}); });
}); });
it("should use custom generateToken function", async () => {
const customGenerateToken = vi.fn(() => "custom_token");
const { customFetchImpl } = await getTestInstance({
plugins: [
magicLink({
async sendMagicLink(data) {
verificationEmail = data;
},
generateToken: customGenerateToken,
}),
],
});
const customClient = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000/api/auth",
});
await customClient.signIn.magicLink({
email: testUser.email,
});
expect(customGenerateToken).toHaveBeenCalled();
expect(verificationEmail.token).toBe("custom_token");
});
}); });
describe("magic link verify", async () => { describe("magic link verify", async () => {

View File

@@ -8,27 +8,28 @@ import { ORGANIZATION_ERROR_CODES } from "./error-codes";
import { BetterAuthError } from "../../error"; import { BetterAuthError } from "../../error";
describe("organization", async (it) => { describe("organization", async (it) => {
const { auth, signInWithTestUser, signInWithUser } = await getTestInstance({ const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
user: { await getTestInstance({
modelName: "users", user: {
}, modelName: "users",
plugins: [ },
organization({ plugins: [
async sendInvitationEmail(data, request) {}, organization({
schema: { async sendInvitationEmail(data, request) {},
organization: { schema: {
modelName: "team", organization: {
modelName: "team",
},
member: {
modelName: "teamMembers",
},
}, },
member: { }),
modelName: "teamMembers", ],
}, logger: {
}, level: "error",
}), },
], });
logger: {
level: "error",
},
});
const { headers } = await signInWithTestUser(); const { headers } = await signInWithTestUser();
const client = createAuthClient({ const client = createAuthClient({
@@ -333,6 +334,36 @@ describe("organization", async (it) => {
); );
}); });
it("should allow leaving organization", async () => {
const newUser = {
email: "leave@org.com",
name: "leaving member",
password: "password",
};
const headers = new Headers();
const res = await client.signUp.email(newUser, {
onSuccess: cookieSetter(headers),
});
const member = await auth.api.addMember({
body: {
organizationId,
userId: res.data?.user.id!,
role: "admin",
},
});
const leaveRes = await client.organization.leave(
{
organizationId,
},
{
headers,
},
);
expect(leaveRes.data).toMatchObject({
userId: res.data?.user.id!,
});
});
it("should allow removing member from organization", async () => { it("should allow removing member from organization", async () => {
const { headers } = await signInWithTestUser(); const { headers } = await signInWithTestUser();
const orgBefore = await client.organization.getFullOrganization({ const orgBefore = await client.organization.getFullOrganization({

View File

@@ -31,6 +31,7 @@ import {
import { import {
addMember, addMember,
getActiveMember, getActiveMember,
leaveOrganization,
removeMember, removeMember,
updateMemberRole, updateMemberRole,
} from "./routes/crud-members"; } from "./routes/crud-members";
@@ -256,6 +257,7 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
removeMember, removeMember,
updateMemberRole: updateMemberRole(options as O), updateMemberRole: updateMemberRole(options as O),
getActiveMember, getActiveMember,
leaveOrganization,
}; };
const roles = { const roles = {

View File

@@ -6,7 +6,7 @@ import type { InferRolesFromOption, Member } from "../schema";
import { APIError } from "better-call"; import { APIError } from "better-call";
import { generateId } from "../../../utils"; import { generateId } from "../../../utils";
import type { OrganizationOptions } from "../organization"; import type { OrganizationOptions } from "../organization";
import { getSessionFromCtx } from "../../../api"; import { getSessionFromCtx, sessionMiddleware } from "../../../api";
import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { ORGANIZATION_ERROR_CODES } from "../error-codes";
import { BASE_ERROR_CODES } from "../../../error/codes"; import { BASE_ERROR_CODES } from "../../../error/codes";
import { hasPermission } from "../has-permission"; import { hasPermission } from "../has-permission";
@@ -402,3 +402,55 @@ export const getActiveMember = createAuthEndpoint(
return ctx.json(member); return ctx.json(member);
}, },
); );
export const leaveOrganization = createAuthEndpoint(
"/organization/leave",
{
method: "POST",
body: z.object({
organizationId: z.string(),
}),
use: [sessionMiddleware, orgMiddleware],
},
async (ctx) => {
const session = ctx.context.session;
const adapter = getOrgAdapter(ctx.context);
const member = await adapter.findMemberByOrgId({
userId: session.user.id,
organizationId: ctx.body.organizationId,
});
if (!member) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND,
});
}
const isOwnerLeaving =
member.role === (ctx.context.orgOptions?.creatorRole || "owner");
if (isOwnerLeaving) {
const members = await ctx.context.adapter.findMany<Member>({
model: "member",
where: [
{
field: "organizationId",
value: ctx.body.organizationId,
},
],
});
const owners = members.filter(
(member) =>
member.role === (ctx.context.orgOptions?.creatorRole || "owner"),
);
if (owners.length <= 1) {
throw new APIError("BAD_REQUEST", {
message:
ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER,
});
}
}
await adapter.deleteMember(member.id);
if (session.session.activeOrganizationId === ctx.body.organizationId) {
await adapter.setActiveOrganization(session.session.token, null);
}
return ctx.json(member);
},
);

View File

@@ -68,7 +68,7 @@ export interface PhoneNumberOptions {
callbackOnVerification?: ( callbackOnVerification?: (
data: { data: {
phoneNumber: string; phoneNumber: string;
user: UserWithPhoneNumber | null; user: UserWithPhoneNumber;
}, },
request?: Request, request?: Request,
) => void | Promise<void>; ) => void | Promise<void>;
@@ -475,13 +475,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
}, },
], ],
}); });
await options?.callbackOnVerification?.(
{
phoneNumber: ctx.body.phoneNumber,
user,
},
ctx.request,
);
if (!user) { if (!user) {
if (options?.signUpOnVerification) { if (options?.signUpOnVerification) {
user = await ctx.context.internalAdapter.createUser({ user = await ctx.context.internalAdapter.createUser({
@@ -501,8 +494,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_USER, message: BASE_ERROR_CODES.FAILED_TO_CREATE_USER,
}); });
} }
} else {
return ctx.json(null);
} }
} else { } else {
user = await ctx.context.internalAdapter.updateUser(user.id, { user = await ctx.context.internalAdapter.updateUser(user.id, {
@@ -510,6 +501,18 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
}); });
} }
if (!user) {
return ctx.json(null);
}
await options?.callbackOnVerification?.(
{
phoneNumber: ctx.body.phoneNumber,
user,
},
ctx.request,
);
if (!user) { if (!user) {
throw new APIError("INTERNAL_SERVER_ERROR", { throw new APIError("INTERNAL_SERVER_ERROR", {
message: BASE_ERROR_CODES.FAILED_TO_UPDATE_USER, message: BASE_ERROR_CODES.FAILED_TO_UPDATE_USER,

View File

@@ -440,7 +440,7 @@ export type BetterAuthOptions = {
/** /**
* List of trusted origins. * List of trusted origins.
*/ */
trustedOrigins?: string[]; trustedOrigins?: string[] | ((request: Request) => string[]);
/** /**
* Rate limiting configuration * Rate limiting configuration
*/ */
@@ -637,7 +637,7 @@ export type BetterAuthOptions = {
| boolean | boolean
| void | void
| { | {
data: User & Record<string, any>; data: Partial<User> & Record<string, any>;
} }
>; >;
/** /**
@@ -678,7 +678,7 @@ export type BetterAuthOptions = {
| boolean | boolean
| void | void
| { | {
data: Session & Record<string, any>; data: Partial<Session> & Record<string, any>;
} }
>; >;
/** /**
@@ -722,7 +722,7 @@ export type BetterAuthOptions = {
| boolean | boolean
| void | void
| { | {
data: Account & Record<string, any>; data: Partial<Account> & Record<string, any>;
} }
>; >;
/** /**
@@ -766,7 +766,7 @@ export type BetterAuthOptions = {
| boolean | boolean
| void | void
| { | {
data: Verification & Record<string, any>; data: Partial<Verification> & Record<string, any>;
} }
>; >;
/** /**

View File

@@ -89,7 +89,7 @@ export const generateDrizzleSchema: SchemaGenerator = async ({
usePlural usePlural
? `${attr.references.model}s` ? `${attr.references.model}s`
: attr.references.model : attr.references.model
}.${attr.references.field})` }.${attr.references.field}, { onDelete: 'cascade' })`
: "" : ""
}`; }`;
}) })

View File

@@ -21,14 +21,14 @@ export const session = pgTable("session", {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'), ipAddress: text('ip_address'),
userAgent: text('user_agent'), userAgent: text('user_agent'),
userId: text('user_id').notNull().references(()=> user.id) userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
}); });
export const account = pgTable("account", { export const account = pgTable("account", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text('account_id').notNull(), accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(), providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(()=> user.id), userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'), accessToken: text('access_token'),
refreshToken: text('refresh_token'), refreshToken: text('refresh_token'),
idToken: text('id_token'), idToken: text('id_token'),
@@ -53,5 +53,5 @@ export const twoFactor = pgTable("two_factor", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
secret: text('secret').notNull(), secret: text('secret').notNull(),
backupCodes: text('backup_codes').notNull(), backupCodes: text('backup_codes').notNull(),
userId: text('user_id').notNull().references(()=> user.id) userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
}); });

View File

@@ -14,8 +14,8 @@ export const expo = (options?: ExpoOptions) => {
init: (ctx) => { init: (ctx) => {
const trustedOrigins = const trustedOrigins =
process.env.NODE_ENV === "development" process.env.NODE_ENV === "development"
? [...(ctx.options.trustedOrigins || []), "exp://"] ? [...(ctx.trustedOrigins || []), "exp://"]
: ctx.options.trustedOrigins; : ctx.trustedOrigins;
return { return {
options: { options: {
trustedOrigins, trustedOrigins,
@@ -56,9 +56,9 @@ export const expo = (options?: ExpoOptions) => {
return; return;
} }
const trustedOrigins = ctx.context.trustedOrigins.filter( const trustedOrigins = ctx.context.trustedOrigins.filter(
(origin) => !origin.startsWith("http"), (origin: string) => !origin.startsWith("http"),
); );
const isTrustedOrigin = trustedOrigins.some((origin) => const isTrustedOrigin = trustedOrigins.some((origin: string) =>
location?.startsWith(origin), location?.startsWith(origin),
); );
if (!isTrustedOrigin) { if (!isTrustedOrigin) {