mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-08 20:37:44 +00:00
Merge branch 'main' into v1.2
This commit is contained in:
@@ -46,3 +46,5 @@ export const {
|
||||
useListOrganizations,
|
||||
useActiveOrganization,
|
||||
} = client;
|
||||
|
||||
client.$store.listen("$sessionSignal", async () => {});
|
||||
|
||||
@@ -173,27 +173,33 @@ export const socialProviders = {
|
||||
Icon: (props: SVGProps<any>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
width="0.98em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
viewBox="0 0 256 262"
|
||||
>
|
||||
<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"
|
||||
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>
|
||||
<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>
|
||||
</svg>
|
||||
),
|
||||
stringIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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>
|
||||
stringIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="0.98em" height="1em" viewBox="0 0 256 262">
|
||||
<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>
|
||||
<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>
|
||||
</svg>`,
|
||||
},
|
||||
linkedin: {
|
||||
|
||||
@@ -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
|
||||
|
||||
**Password**
|
||||
|
||||
@@ -73,7 +73,7 @@ The `getSession` function retrieves the current active session.
|
||||
```ts client="client.ts"
|
||||
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.
|
||||
@@ -85,7 +85,7 @@ The `useSession` action provides a reactive way to access the current session.
|
||||
```ts client="client.ts"
|
||||
import { authClient } from "@/lib/client"
|
||||
|
||||
const session = await authClient.useSession()
|
||||
const { data: session } = authClient.useSession()
|
||||
```
|
||||
|
||||
### List Sessions
|
||||
|
||||
@@ -37,7 +37,7 @@ import { cors } from "hono/cors";
|
||||
const app = new Hono();
|
||||
|
||||
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({
|
||||
origin: "http://localhost:3001", // replace with your origin
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
@@ -82,7 +82,7 @@ app.use("*", async (c, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/**", (c) => {
|
||||
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Better Auth is a framework-agnostic authentication and authorization framework f
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
```ts title="example.ts"
|
||||
await authClient.emailOtp.sendVerificationOtp({
|
||||
const { data, error } = await authClient.emailOtp.sendVerificationOtp({
|
||||
email: "user-email@email.com",
|
||||
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.
|
||||
|
||||
```ts title="example.ts"
|
||||
const user = await authClient.signIn.emailOtp({
|
||||
const { data, error } = await authClient.signIn.emailOtp({
|
||||
email: "user-email@email.com",
|
||||
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.
|
||||
|
||||
```ts title="example.ts"
|
||||
const user = await authClient.emailOtp.verifyEmail({
|
||||
const { data, error } = await authClient.emailOtp.verifyEmail({
|
||||
email: "user-email@email.com",
|
||||
otp: "123456"
|
||||
})
|
||||
@@ -88,7 +88,7 @@ const user = await authClient.emailOtp.verifyEmail({
|
||||
To reset the user's password, use the `resetPassword()` method.
|
||||
|
||||
```ts title="example.ts"
|
||||
await authClient.emailOtp.resetPassword({
|
||||
const { data, error } = await authClient.emailOtp.resetPassword({
|
||||
email: "user-email@email.com",
|
||||
otp: "123456",
|
||||
password: "password"
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the server Plugin
|
||||
@@ -43,6 +44,7 @@ Magic link or email link is a way to authenticate users without a password. When
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
@@ -54,7 +56,7 @@ To sign in with a magic link, you need to call `signIn.magicLink` with the user'
|
||||
```ts title="magic-link.ts"
|
||||
const { data, error } = await authClient.signIn.magicLink({
|
||||
email: "user@email.com",
|
||||
callbackURL: "/dashboard" //redirect after successful login (optional)
|
||||
callbackURL: "/dashboard", //redirect after successful login (optional)
|
||||
});
|
||||
```
|
||||
|
||||
@@ -65,7 +67,7 @@ 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.
|
||||
|
||||
<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>
|
||||
|
||||
If you want to handle the verification manually, (e.g, if you send the user a different url), you can use the `verify` function.
|
||||
@@ -73,8 +75,8 @@ If you want to handle the verification manually, (e.g, if you send the user a di
|
||||
```ts title="magic-link.ts"
|
||||
const { data, error } = await authClient.magicLink.verify({
|
||||
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).
|
||||
|
||||
**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>
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
@@ -85,7 +85,6 @@ const authClient = createAuthClient({
|
||||
passkeyClient(), // [!code highlight]
|
||||
], // [!code highlight]
|
||||
});
|
||||
// ---cut---
|
||||
const data = await authClient.passkey.addPasskey();
|
||||
```
|
||||
|
||||
@@ -111,7 +110,6 @@ const authClient = createAuthClient({
|
||||
passkeyClient(), // [!code highlight]
|
||||
], // [!code highlight]
|
||||
});
|
||||
// ---cut---
|
||||
const data = await authClient.signIn.passkey();
|
||||
```
|
||||
|
||||
|
||||
@@ -141,6 +141,35 @@ describe("Origin Check", async (it) => {
|
||||
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) => {
|
||||
const client = createAuthClient({
|
||||
baseURL: "https://sub-domain.my-site.com",
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { GenericEndpointContext } from "../../types";
|
||||
* trustedOrigins.
|
||||
*/
|
||||
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.request?.method !== "POST") {
|
||||
if (ctx.request?.method !== "POST" || !ctx.request) {
|
||||
return;
|
||||
}
|
||||
const { body, query, context } = ctx;
|
||||
@@ -19,7 +19,12 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
const redirectURL = body?.redirectTo;
|
||||
const errorCallbackURL = body?.errorCallbackURL;
|
||||
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 matchesPattern = (url: string, pattern: string): boolean => {
|
||||
@@ -66,9 +71,19 @@ export const originCheck = (
|
||||
getValue: (ctx: GenericEndpointContext) => string,
|
||||
) =>
|
||||
createAuthMiddleware(async (ctx) => {
|
||||
if (!ctx.request) {
|
||||
return;
|
||||
}
|
||||
const { context } = 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 => {
|
||||
if (url.startsWith("/")) {
|
||||
|
||||
@@ -35,7 +35,7 @@ function getRetryAfter(lastRequest: number, window: number) {
|
||||
}
|
||||
|
||||
function createDBStorage(ctx: AuthContext, modelName?: string) {
|
||||
const model = "rateLimit";
|
||||
const model = ctx.options.rateLimit?.modelName || "rateLimit";
|
||||
const db = ctx.adapter;
|
||||
return {
|
||||
get: async (key: string) => {
|
||||
|
||||
@@ -40,7 +40,11 @@ export const betterAuth = <O extends BetterAuthOptions>(options: O) => {
|
||||
ctx.baseURL = baseURL;
|
||||
}
|
||||
ctx.trustedOrigins = [
|
||||
...(options.trustedOrigins || []),
|
||||
...(options.trustedOrigins
|
||||
? Array.isArray(options.trustedOrigins)
|
||||
? options.trustedOrigins
|
||||
: options.trustedOrigins(request)
|
||||
: []),
|
||||
ctx.baseURL,
|
||||
url.origin,
|
||||
];
|
||||
|
||||
@@ -94,8 +94,8 @@ export type InferRoute<API, COpts extends ClientOptions> = API extends Record<
|
||||
? C extends InputContext<any, any>
|
||||
? <
|
||||
FetchOptions extends BetterFetchOption<
|
||||
C["body"] & Record<string, any>,
|
||||
C["query"] & Record<string, any>,
|
||||
Partial<C["body"]> & Record<string, any>,
|
||||
Partial<C["query"]> & Record<string, any>,
|
||||
C["params"]
|
||||
>,
|
||||
>(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -24,13 +24,16 @@ export function getWithHooks(
|
||||
for (const hook of hooks || []) {
|
||||
const toRun = hook[model]?.create?.before;
|
||||
if (toRun) {
|
||||
const result = await toRun(data as any);
|
||||
const result = await toRun(actualData as any);
|
||||
if (result === false) {
|
||||
return null;
|
||||
}
|
||||
const isObject = typeof result === "object" && "data" in result;
|
||||
if (isObject) {
|
||||
actualData = result.data as T;
|
||||
actualData = {
|
||||
...actualData,
|
||||
...result.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ function getTrustedOrigins(options: BetterAuthOptions) {
|
||||
return [];
|
||||
}
|
||||
const trustedOrigins = [new URL(baseURL).origin];
|
||||
if (options.trustedOrigins) {
|
||||
if (options.trustedOrigins && Array.isArray(options.trustedOrigins)) {
|
||||
trustedOrigins.push(...options.trustedOrigins);
|
||||
}
|
||||
const envTrustedOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS;
|
||||
|
||||
@@ -138,7 +138,8 @@ export async function handleOAuthUserInfo(
|
||||
c.request,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
logger.error(e);
|
||||
if (e instanceof APIError) {
|
||||
return {
|
||||
error: e.message,
|
||||
|
||||
@@ -120,7 +120,6 @@ export const admin = <O extends AdminOptions>(options?: O) => {
|
||||
return {
|
||||
data: {
|
||||
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,
|
||||
schema: mergeSchema(schema, opts.schema),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { APIError } from "better-call";
|
||||
import { z } from "zod";
|
||||
import { createAuthEndpoint } from "../../api";
|
||||
import { createAuthEndpoint, sessionMiddleware } from "../../api";
|
||||
import { setSessionCookie } from "../../cookies";
|
||||
import {
|
||||
createAuthorizationURL,
|
||||
@@ -13,6 +13,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account";
|
||||
import { generateState, parseState } from "../../oauth2/state";
|
||||
import type { BetterAuthPlugin, User } from "../../types";
|
||||
import { decodeJwt } from "jose";
|
||||
import { BASE_ERROR_CODES } from "../../error/codes";
|
||||
|
||||
/**
|
||||
* Configuration interface for generic OAuth providers.
|
||||
@@ -475,7 +476,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
let tokens: OAuth2Tokens | undefined = undefined;
|
||||
const parsedState = await parseState(ctx);
|
||||
|
||||
const { callbackURL, codeVerifier, errorURL, newUserURL } =
|
||||
const { callbackURL, codeVerifier, errorURL, newUserURL, link } =
|
||||
parsedState;
|
||||
const code = ctx.query.code;
|
||||
|
||||
@@ -544,6 +545,28 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
? await provider.mapProfileToUser(userInfo)
|
||||
: 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, {
|
||||
userInfo: {
|
||||
...userInfo,
|
||||
@@ -586,6 +609,102 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
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,
|
||||
} satisfies BetterAuthPlugin;
|
||||
|
||||
@@ -42,6 +42,10 @@ interface MagicLinkOptions {
|
||||
window: number;
|
||||
max: number;
|
||||
};
|
||||
/**
|
||||
* Custom function to generate a token
|
||||
*/
|
||||
generateToken?: (email: string) => Promise<string> | string;
|
||||
}
|
||||
|
||||
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({
|
||||
identifier: verificationToken,
|
||||
value: JSON.stringify({ email, name: ctx.body.name }),
|
||||
|
||||
@@ -133,6 +133,36 @@ describe("magic link", async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ORGANIZATION_ERROR_CODES } from "./error-codes";
|
||||
import { BetterAuthError } from "../../error";
|
||||
|
||||
describe("organization", async (it) => {
|
||||
const { auth, signInWithTestUser, signInWithUser } = await getTestInstance({
|
||||
const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
|
||||
await getTestInstance({
|
||||
user: {
|
||||
modelName: "users",
|
||||
},
|
||||
@@ -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 () => {
|
||||
const { headers } = await signInWithTestUser();
|
||||
const orgBefore = await client.organization.getFullOrganization({
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import {
|
||||
addMember,
|
||||
getActiveMember,
|
||||
leaveOrganization,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
} from "./routes/crud-members";
|
||||
@@ -256,6 +257,7 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
|
||||
removeMember,
|
||||
updateMemberRole: updateMemberRole(options as O),
|
||||
getActiveMember,
|
||||
leaveOrganization,
|
||||
};
|
||||
|
||||
const roles = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { InferRolesFromOption, Member } from "../schema";
|
||||
import { APIError } from "better-call";
|
||||
import { generateId } from "../../../utils";
|
||||
import type { OrganizationOptions } from "../organization";
|
||||
import { getSessionFromCtx } from "../../../api";
|
||||
import { getSessionFromCtx, sessionMiddleware } from "../../../api";
|
||||
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
|
||||
import { BASE_ERROR_CODES } from "../../../error/codes";
|
||||
import { hasPermission } from "../has-permission";
|
||||
@@ -402,3 +402,55 @@ export const getActiveMember = createAuthEndpoint(
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ export interface PhoneNumberOptions {
|
||||
callbackOnVerification?: (
|
||||
data: {
|
||||
phoneNumber: string;
|
||||
user: UserWithPhoneNumber | null;
|
||||
user: UserWithPhoneNumber;
|
||||
},
|
||||
request?: Request,
|
||||
) => 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 (options?.signUpOnVerification) {
|
||||
user = await ctx.context.internalAdapter.createUser({
|
||||
@@ -501,8 +494,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
|
||||
message: BASE_ERROR_CODES.FAILED_TO_CREATE_USER,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return ctx.json(null);
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: BASE_ERROR_CODES.FAILED_TO_UPDATE_USER,
|
||||
|
||||
@@ -440,7 +440,7 @@ export type BetterAuthOptions = {
|
||||
/**
|
||||
* List of trusted origins.
|
||||
*/
|
||||
trustedOrigins?: string[];
|
||||
trustedOrigins?: string[] | ((request: Request) => string[]);
|
||||
/**
|
||||
* Rate limiting configuration
|
||||
*/
|
||||
@@ -637,7 +637,7 @@ export type BetterAuthOptions = {
|
||||
| boolean
|
||||
| void
|
||||
| {
|
||||
data: User & Record<string, any>;
|
||||
data: Partial<User> & Record<string, any>;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
@@ -678,7 +678,7 @@ export type BetterAuthOptions = {
|
||||
| boolean
|
||||
| void
|
||||
| {
|
||||
data: Session & Record<string, any>;
|
||||
data: Partial<Session> & Record<string, any>;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
@@ -722,7 +722,7 @@ export type BetterAuthOptions = {
|
||||
| boolean
|
||||
| void
|
||||
| {
|
||||
data: Account & Record<string, any>;
|
||||
data: Partial<Account> & Record<string, any>;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
@@ -766,7 +766,7 @@ export type BetterAuthOptions = {
|
||||
| boolean
|
||||
| void
|
||||
| {
|
||||
data: Verification & Record<string, any>;
|
||||
data: Partial<Verification> & Record<string, any>;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@ export const generateDrizzleSchema: SchemaGenerator = async ({
|
||||
usePlural
|
||||
? `${attr.references.model}s`
|
||||
: attr.references.model
|
||||
}.${attr.references.field})`
|
||||
}.${attr.references.field}, { onDelete: 'cascade' })`
|
||||
: ""
|
||||
}`;
|
||||
})
|
||||
|
||||
@@ -21,14 +21,14 @@ export const session = pgTable("session", {
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
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", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text('account_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'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
@@ -53,5 +53,5 @@ export const twoFactor = pgTable("two_factor", {
|
||||
id: text("id").primaryKey(),
|
||||
secret: text('secret').notNull(),
|
||||
backupCodes: text('backup_codes').notNull(),
|
||||
userId: text('user_id').notNull().references(()=> user.id)
|
||||
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ export const expo = (options?: ExpoOptions) => {
|
||||
init: (ctx) => {
|
||||
const trustedOrigins =
|
||||
process.env.NODE_ENV === "development"
|
||||
? [...(ctx.options.trustedOrigins || []), "exp://"]
|
||||
: ctx.options.trustedOrigins;
|
||||
? [...(ctx.trustedOrigins || []), "exp://"]
|
||||
: ctx.trustedOrigins;
|
||||
return {
|
||||
options: {
|
||||
trustedOrigins,
|
||||
@@ -56,9 +56,9 @@ export const expo = (options?: ExpoOptions) => {
|
||||
return;
|
||||
}
|
||||
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),
|
||||
);
|
||||
if (!isTrustedOrigin) {
|
||||
|
||||
Reference in New Issue
Block a user