feat: custom session response (#579)

This commit is contained in:
Bereket Engida
2024-11-19 21:16:23 +03:00
committed by GitHub
parent a3f0793d68
commit f5f60a23e8
28 changed files with 425 additions and 95 deletions

View File

@@ -8,6 +8,7 @@ import {
oneTapClient,
} from "better-auth/client/plugins";
import { toast } from "sonner";
import { customSessionClient } from "./auth/plugins/session-client";
export const client = createAuthClient({
plugins: [
@@ -22,6 +23,7 @@ export const client = createAuthClient({
oneTapClient({
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
}),
customSessionClient(),
],
fetchOptions: {
onError(e) {

View File

@@ -8,6 +8,7 @@ import {
twoFactor,
oneTap,
oAuthProxy,
createAuthEndpoint,
} from "better-auth/plugins";
import { reactInvitationEmail } from "./email/invitation";
import { LibsqlDialect } from "@libsql/kysely-libsql";
@@ -16,7 +17,7 @@ import { resend } from "./email/resend";
import { MysqlDialect } from "kysely";
import { createPool } from "mysql2/promise";
import { nextCookies } from "better-auth/next-js";
import * as ac from "./access-control";
import { customSession } from "./auth/plugins/custom-session";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || "";
@@ -112,12 +113,6 @@ export const auth = betterAuth({
},
plugins: [
organization({
ac: ac.ac,
roles: {
admin: ac.admin,
owner: ac.owner,
member: ac.member,
},
async sendInvitationEmail(data) {
const res = await resend.emails.send({
from,
@@ -159,5 +154,6 @@ export const auth = betterAuth({
oneTap(),
oAuthProxy(),
nextCookies(),
customSession(),
],
});

View File

@@ -0,0 +1,32 @@
import { BetterAuthPlugin } from "better-auth";
import { createAuthEndpoint } from "better-auth/plugins";
import { getSessionFromCtx } from "better-auth/api";
export const customSession = () => {
return {
id: "custom-session",
endpoints: {
getSession: createAuthEndpoint(
"/get-session",
{
method: "GET",
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session) {
return ctx.json(null);
}
const roles: {
id: number;
name: string;
}[] = [];
return ctx.json({
user: session.user,
session: session.session,
roles,
});
},
),
},
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,9 @@
import { BetterAuthClientPlugin } from "better-auth";
import { customSession } from "./custom-session";
export const customSessionClient = () => {
return {
id: "session-client",
$InferServerPlugin: {} as ReturnType<typeof customSession>,
} satisfies BetterAuthClientPlugin;
};

View File

@@ -47,6 +47,8 @@ import { authClient } from "@/lib/client"
const session = await authClient.getSession()
```
To learn how to customize the session response check the [Customizing Session Response](#customizing-session-response) section.
### Use Session
The `useSession` action provides a reactive way to access the current session.
@@ -146,3 +148,79 @@ auth.api.getSession({
headers: req.headers, // pass the headers
});
```
### Customizing Session Response
When you call `getSession` or `useSession`, the session data is returned as a `user` and `session` object. You can customize this response using the `customSession` plugin.
```ts title="auth.ts"
import { customSession } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
customSession(async ({ user, session }) => {
const roles = findUserRoles(session.session.userId);
return {
roles,
user: {
...user,
newField: "newField",
},
session
};
}),
],
});
```
This will add `roles` and `user.newField` to the session response.
**Infer on the Client**
```ts title="client.ts"
import type { auth } from "@/lib/auth"; // Import the auth instance as a type
const authClient = createAuthClient({
plugins: [customSessionClient<typeof auth>()],
});
const { data } = await authClient.useSession();
const { data: sessionData } = await authClient.getSession();
// data.roles
// data.user.newField
```
**Some Caveats**:
- The passed `session` object to the callback does not infer fields added by plugins.
However, as a workaround, you can pull up your auth options and pass it to the plugin to infer the fields.
```ts
import { betterAuth, BetterAuthOptions } from "better-auth";
const options = {
//...config options
plugins: [
//...plugins
]
} satisfies BetterAuthOptions;
export const auth = betterAuth({
...options,
plugins: [{
...options.plugins,
customSession(async ({ user, session }) => {
// now both user and session will infer the fields added by plugins and your custom fields
retunr {
user,
session
}
}, options), // pass options here // [!code highlight]
}]
})
```
- If you cannot use the `auth` instance as a type, inference will not work on the client.
- Session caching, including secondary storage or cookie cache, does not include custom fields. Each time the session is fetched, your custom session function will be called.

View File

@@ -1,4 +1,4 @@
import { APIError, type Context } from "better-call";
import { APIError } from "better-call";
import { createAuthEndpoint, createAuthMiddleware } from "../call";
import { getDate } from "../../utils/date";
import { deleteSessionCookie, setSessionCookie } from "../../cookies";

View File

@@ -1,20 +1,14 @@
import type { Endpoint, Prettify } from "better-call";
import { getEndpoints, router } from "./api";
import { init } from "./init";
import type { BetterAuthOptions } from "./types/options";
import type { InferPluginTypes, InferSession, InferUser } from "./types";
import type {
InferPluginTypes,
InferSession,
InferUser,
PrettifyDeep,
} from "./types";
import { getBaseURL } from "./utils/url";
type InferAPI<API> = Omit<
API,
API extends { [key in infer K]: Endpoint }
? K extends string
? API[K]["options"]["metadata"] extends { isAction: false }
? K
: never
: never
: never
>;
import type { FilterActions, InferAPI } from "./types/api";
export const betterAuth = <O extends BetterAuthOptions>(options: O) => {
const authContext = init(options);
@@ -46,8 +40,8 @@ export const betterAuth = <O extends BetterAuthOptions>(options: O) => {
$context: authContext,
$Infer: {} as {
Session: {
session: Prettify<InferSession<O>>;
user: Prettify<InferUser<O>>;
session: PrettifyDeep<InferSession<O>>;
user: PrettifyDeep<InferUser<O>>;
};
} & InferPluginTypes<O>,
};
@@ -55,6 +49,6 @@ export const betterAuth = <O extends BetterAuthOptions>(options: O) => {
export type Auth = {
handler: (request: Request) => Promise<Response>;
api: InferAPI<ReturnType<typeof router>["endpoints"]>;
api: FilterActions<ReturnType<typeof router>["endpoints"]>;
options: BetterAuthOptions;
};

View File

@@ -27,7 +27,7 @@ export const getClientConfig = <O extends ClientOptions>(options?: O) => {
...pluginsFetchPlugins,
],
});
const { $sessionSignal, session } = getSessionAtom<O>($fetch);
const { $sessionSignal, session } = getSessionAtom($fetch);
const plugins = options?.plugins || [];
let pluginsActions = {} as Record<string, any>;
let pluginsAtoms = {

View File

@@ -1,3 +1,12 @@
import type { BetterAuthPlugin } from "../types";
import type { BetterAuthClientPlugin } from "./types";
export * from "./vanilla";
export * from "./query";
export * from "./types";
export const InferPlugin = <T extends BetterAuthPlugin>() => {
return {
id: "infer-server-plugin",
$InferServerPlugin: {} as T,
} satisfies BetterAuthClientPlugin;
};

View File

@@ -138,7 +138,11 @@ export type InferRoute<API, COpts extends ClientOptions> = API extends {
]
) => Promise<
BetterFetchResponse<
InferReturn<Awaited<R>, COpts>,
T["options"]["metadata"] extends {
CUSTOM_SESSION: boolean;
}
? NonNullable<Awaited<R>>
: InferReturn<Awaited<R>, COpts>,
unknown,
FetchOptions["throw"] extends true
? true

View File

@@ -13,3 +13,5 @@ export * from "../../plugins/jwt/client";
export * from "../../plugins/multi-session/client";
export * from "../../plugins/email-otp/client";
export * from "../../plugins/one-tap/client";
export * from "../../plugins/custom-session/client";
export * from "./infer-plugin";

View File

@@ -0,0 +1,23 @@
import type { BetterAuthClientPlugin, BetterAuthOptions } from "../../types";
export const InferServerPlugin = <
AuthOrOption extends
| BetterAuthOptions
| {
options: BetterAuthOptions;
},
ID extends string,
>() => {
type Option = AuthOrOption extends { options: infer O } ? O : AuthOrOption;
type Plugin = Option["plugins"] extends Array<infer P>
? P extends {
id: ID;
}
? P
: never
: never;
return {
id: "infer-server-plugin",
$InferServerPlugin: {} as Plugin,
} satisfies BetterAuthClientPlugin;
};

View File

@@ -10,7 +10,10 @@ import type {
} from "../types";
import { createDynamicPathProxy } from "../proxy";
import type { UnionToIntersection } from "../../types/helper";
import type { BetterFetchError } from "@better-fetch/fetch";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import { useStore } from "./react-store";
function getAtomKey(str: string) {
@@ -69,21 +72,23 @@ export function createAuthClient<Option extends ClientOptions>(
atomListeners,
);
type Session = {
session: InferSessionFromClient<Option>;
user: InferUserFromClient<Option>;
};
type ClientAPI = InferClientAPI<Option>;
type Session = ClientAPI extends {
getSession: () => Promise<BetterFetchResponse<infer D>>;
}
? D
: ClientAPI;
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
ClientAPI &
InferActions<Option> & {
useSession: () => {
data: Session | null;
data: Session;
isPending: boolean;
error: BetterFetchError | null;
};
$Infer: {
Session: Session;
Session: NonNullable<Session>;
};
$fetch: typeof $fetch;
$store: typeof $store;

View File

@@ -1,22 +1,13 @@
import type { BetterFetch } from "@better-fetch/fetch";
import { atom } from "nanostores";
import type { Prettify } from "../types/helper";
import type {
ClientOptions,
InferSessionFromClient,
InferUserFromClient,
} from "./types";
import { useAuthQuery } from "./query";
import type { Session, User } from "../types";
export function getSessionAtom<Option extends ClientOptions>(
$fetch: BetterFetch,
) {
type UserWithAdditionalFields = InferUserFromClient<Option>;
type SessionWithAdditionalFields = InferSessionFromClient<Option>;
export function getSessionAtom($fetch: BetterFetch) {
const $signal = atom<boolean>(false);
const session = useAuthQuery<{
user: Prettify<UserWithAdditionalFields>;
session: Prettify<SessionWithAdditionalFields>;
user: User;
session: Session;
}>($signal, "/get-session", $fetch, {
method: "GET",
});

View File

@@ -12,7 +12,10 @@ import type {
} from "../types";
import type { Accessor } from "solid-js";
import type { UnionToIntersection } from "../../types/helper";
import type { BetterFetchError } from "@better-fetch/fetch";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import { useStore } from "./solid-store";
function getAtomKey(str: string) {
@@ -62,21 +65,23 @@ export function createAuthClient<Option extends ClientOptions>(
pluginsAtoms,
atomListeners,
);
type Session = {
session: InferSessionFromClient<Option>;
user: InferUserFromClient<Option>;
};
type ClientAPI = InferClientAPI<Option>;
type Session = ClientAPI extends {
getSession: () => Promise<BetterFetchResponse<infer D>>;
}
? D
: ClientAPI;
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
InferActions<Option> & {
useSession: () => Accessor<{
data: Session | null;
data: Session;
isPending: boolean;
isRefetching: boolean;
error: BetterFetchError | null;
}>;
$Infer: {
Session: Session;
Session: NonNullable<Session>;
};
$fetch: typeof $fetch;
};

View File

@@ -12,7 +12,10 @@ import type {
import { createDynamicPathProxy } from "../proxy";
import type { UnionToIntersection } from "../../types/helper";
import type { Atom } from "nanostores";
import type { BetterFetchError } from "@better-fetch/fetch";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
type InferResolvedHooks<O extends ClientOptions> = O["plugins"] extends Array<
infer Plugin
@@ -60,15 +63,17 @@ export function createAuthClient<Option extends ClientOptions>(
pluginsAtoms,
atomListeners,
);
type Session = {
session: InferSessionFromClient<Option>;
user: InferUserFromClient<Option>;
};
type ClientAPI = InferClientAPI<Option>;
type Session = ClientAPI extends {
getSession: () => Promise<BetterFetchResponse<infer D>>;
}
? D
: ClientAPI;
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
InferActions<Option> & {
useSession: () => Atom<{
data: Session | null;
data: Session;
error: BetterFetchError | null;
isPending: boolean;
isRefetching: boolean;
@@ -76,7 +81,7 @@ export function createAuthClient<Option extends ClientOptions>(
$fetch: typeof $fetch;
$store: typeof $store;
$Infer: {
Session: Session;
Session: NonNullable<Session>;
};
};
}

View File

@@ -68,7 +68,8 @@ export interface ClientOptions {
export type InferClientAPI<O extends ClientOptions> = InferRoutes<
O["plugins"] extends Array<any>
? (O["plugins"] extends Array<infer Pl>
? Auth["api"] &
(O["plugins"] extends Array<infer Pl>
? UnionToIntersection<
Pl extends {
$InferServerPlugin: infer Plug;
@@ -80,8 +81,7 @@ export type InferClientAPI<O extends ClientOptions> = InferRoutes<
: {}
: {}
>
: {}) &
Auth["api"]
: {})
: Auth["api"],
O
>;

View File

@@ -12,7 +12,10 @@ import type {
import { createDynamicPathProxy } from "./proxy";
import type { UnionToIntersection } from "../types/helper";
import type { Atom } from "nanostores";
import type { BetterFetchError } from "@better-fetch/fetch";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
type InferResolvedHooks<O extends ClientOptions> = O["plugins"] extends Array<
infer Plugin
@@ -60,24 +63,24 @@ export function createAuthClient<Option extends ClientOptions>(
pluginsAtoms,
atomListeners,
);
type ClientAPI = InferClientAPI<Option>;
type Session = ClientAPI extends {
getSession: () => Promise<BetterFetchResponse<infer D>>;
}
? D
: ClientAPI;
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
ClientAPI &
InferActions<Option> & {
useSession: Atom<{
data: {
session: InferSessionFromClient<Option>;
user: InferUserFromClient<Option>;
};
data: Session;
error: BetterFetchError | null;
isPending: boolean;
}>;
$fetch: typeof $fetch;
$store: typeof $store;
$Infer: {
Session: {
session: InferSessionFromClient<Option>;
user: InferUserFromClient<Option>;
};
Session: NonNullable<Session>;
};
};
}

View File

@@ -7,13 +7,14 @@ import type {
ClientOptions,
InferActions,
InferClientAPI,
InferSessionFromClient,
InferUserFromClient,
IsSignal,
} from "../types";
import { createDynamicPathProxy } from "../proxy";
import type { UnionToIntersection } from "../../types/helper";
import type { BetterFetchError } from "@better-fetch/fetch";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
function getAtomKey(str: string) {
return `use${capitalizeFirstLetter(str)}`;
@@ -55,14 +56,16 @@ export function createAuthClient<Option extends ClientOptions>(
resolvedHooks[getAtomKey(key)] = () => useStore(value);
}
type Session = {
session: InferSessionFromClient<Option>;
user: InferUserFromClient<Option>;
};
type ClientAPI = InferClientAPI<Option>;
type Session = ClientAPI extends {
getSession: () => Promise<BetterFetchResponse<infer D>>;
}
? D
: ClientAPI;
function useSession(): () => DeepReadonly<
Ref<{
data: Session | null;
data: Session;
isPending: boolean;
isRefetching: boolean;
error: BetterFetchError | null;
@@ -114,12 +117,13 @@ export function createAuthClient<Option extends ClientOptions>(
pluginsAtoms,
atomListeners,
);
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
InferClientAPI<Option> &
InferActions<Option> & {
useSession: typeof useSession;
$Infer: {
Session: Session;
Session: NonNullable<Session>;
};
$fetch: typeof $fetch;
$store: typeof $store;

View File

@@ -4,9 +4,21 @@ import type { BetterAuthOptions } from "../types";
import type { Adapter } from "../types/adapter";
import { createKyselyAdapter } from "../adapters/kysely-adapter/dialect";
import { kyselyAdapter } from "../adapters/kysely-adapter";
import { isDevelopment } from "../utils/env";
import { memoryAdapter } from "../adapters/memory-adapter";
import { logger } from "../utils";
const memoryDB = {};
export async function getAdapter(options: BetterAuthOptions): Promise<Adapter> {
if (!options.database) {
// If no database is provided, use memory adapter in development
if (isDevelopment) {
logger.warn(
"No database configuration provided. Using memory adapter in development",
);
return memoryAdapter(memoryDB)(options);
}
throw new BetterAuthError("Database configuration is required");
}

View File

@@ -0,0 +1,10 @@
import { InferServerPlugin } from "../../client/plugins";
import type { BetterAuthOptions } from "../../types";
export const customSessionClient = <
A extends {
options: BetterAuthOptions;
},
>() => {
return InferServerPlugin<A, "custom-session">();
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { customSession } from ".";
import { admin } from "../admin";
import { createAuthClient } from "../../client";
import { customSessionClient } from "./client";
import type { BetterAuthOptions, InferUser } from "../../types";
import { adminClient } from "../admin/client";
describe("Custom Session Plugin Tests", async () => {
const options = {
plugins: [admin()],
} satisfies BetterAuthOptions;
const { auth, signInWithTestUser, testUser, customFetchImpl } =
await getTestInstance({
plugins: [
...options.plugins,
customSession(async ({ user, session }) => {
const newData = {
message: "Hello, World!",
};
return {
user: {
firstName: user.name.split(" ")[0],
lastName: user.name.split(" ")[1],
},
newData,
session,
};
}, options),
],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [customSessionClient<typeof auth>(), adminClient()],
fetchOptions: { customFetchImpl },
});
it("should return the session", async () => {
const { headers } = await signInWithTestUser();
const session = await auth.api.getSession({ headers });
const s = await client.getSession({ fetchOptions: { headers } });
expect(s.data?.newData).toEqual({ message: "Hello, World!" });
expect(session?.newData).toEqual({ message: "Hello, World!" });
});
});

View File

@@ -0,0 +1,43 @@
import { createAuthEndpoint, getSessionFromCtx } from "../../api";
import type {
BetterAuthOptions,
BetterAuthPlugin,
InferSession,
InferUser,
Session,
User,
} from "../../types";
export const customSession = <
Returns extends Record<string, any>,
O extends BetterAuthOptions = BetterAuthOptions,
>(
fn: (session: {
user: InferUser<O>;
session: InferSession<O>;
}) => Promise<Returns>,
options?: O,
) => {
return {
id: "custom-session",
endpoints: {
getSession: createAuthEndpoint(
"/get-session",
{
method: "GET",
metadata: {
CUSTOM_SESSION: true,
},
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session) {
return ctx.json(null);
}
const fnResult = await fn(session as any);
return ctx.json(fnResult);
},
),
},
} satisfies BetterAuthPlugin;
};

View File

@@ -16,3 +16,4 @@ export * from "./multi-session";
export * from "./email-otp";
export * from "./one-tap";
export * from "./oauth-proxy";
export * from "./custom-session";

View File

@@ -106,7 +106,7 @@ export async function getTestInstance<
const testUser = {
email: "test@test.com",
password: "test123456",
name: "test",
name: "test user",
...config?.testUser,
};
async function createTestUser() {

View File

@@ -0,0 +1,44 @@
import type { Endpoint } from "better-call";
import type { PrettifyDeep, UnionToIntersection } from "./helper";
export type FilteredAPI<API> = Omit<
API,
API extends { [key in infer K]: Endpoint }
? K extends string
? K extends "getSession"
? K
: API[K]["options"]["metadata"] extends { isAction: false }
? K
: never
: never
: never
>;
export type FilterActions<API> = Omit<
API,
API extends { [key in infer K]: Endpoint }
? K extends string
? API[K]["options"]["metadata"] extends { isAction: false }
? K
: never
: never
: never
>;
export type InferSessionAPI<API> = API extends {
[key: string]: infer E;
}
? UnionToIntersection<
E extends Endpoint
? E["path"] extends "/get-session"
? {
getSession: (context: {
headers: Headers;
}) => Promise<PrettifyDeep<Awaited<ReturnType<E>>>>;
}
: never
: never
>
: never;
export type InferAPI<API> = InferSessionAPI<API> & FilteredAPI<API>;

View File

@@ -5,6 +5,17 @@ export type LiteralString = "" | (string & Record<never, never>);
export type OmitId<T extends { id: unknown }> = Omit<T, "id">;
export type Prettify<T> = Omit<T, never>;
export type PrettifyDeep<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? T[K]
: T[K] extends object
? T[K] extends Array<any>
? T[K]
: T[K] extends Date
? T[K]
: PrettifyDeep<T[K]>
: T[K];
} & {};
export type LiteralUnion<LiteralType, BaseType extends Primitive> =
| LiteralType
| (BaseType & Record<never, never>);

View File

@@ -66,7 +66,7 @@ export interface BetterAuthOptions {
/**
* Database configuration
*/
database:
database?:
| PostgresPool
| MysqlPool
| Database