@contract-kit/provider-auth-better-auth
v1.0.0
Published
Better Auth provider for contract-kit - adds auth port for authentication and session management
Maintainers
Readme
@contract-kit/provider-auth-better-auth
Better Auth provider for Contract Kit - adds authentication and session management via Better Auth.
Overview
This provider wraps an already-configured Better Auth server instance and exposes the shared AuthPort from @contract-kit/ports on ctx.ports.auth. It follows the ports & adapters pattern, providing a stable interface for authentication while letting your application own the configuration.
What this provider does:
- Wraps a Better Auth instance with a simple, stable API
- Extends
ports.authwithgetSession,getUser, andrequireUsermethods - Maintains type safety for your custom User type
- Records auth checks in devtools when devtools is installed
What this provider does NOT do:
- Define database schema (you own your user table)
- Define the User type (you define it in your app)
- Configure Better Auth (secrets, session strategy, etc. stay in your app)
- Implement login/signup routes (use Better Auth's routes directly)
- Manage RBAC/permissions (that's application logic)
Installation
bun add @contract-kit/ports @contract-kit/provider-auth-better-auth better-authTypeScript requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Usage
1. Configure Better Auth in your app
First, set up Better Auth with your database and configuration:
// lib/better-auth.ts
import { betterAuth } from "better-auth";
import { db } from "./db"; // Your Drizzle/Prisma/etc. client
export const auth = betterAuth({
database: db,
emailAndPassword: {
enabled: true,
},
// ...other Better Auth configuration
});
// Export your user type
export type AuthUser = typeof auth.$Infer.Session.user;2. Define your ports type
Add the auth port to your application's ports type:
// ports/index.ts
import type { AuthPort } from "@contract-kit/ports";
import type { AuthUser } from "@/lib/better-auth";
export type AppPorts = {
auth: AuthPort<AuthUser>;
// ...other ports (db, mailer, eventBus, etc.)
};3. Wire the provider into Contract Kit
Register the provider when creating your server:
// server/providers.ts
import { createAuthBetterAuthProvider } from "@contract-kit/provider-auth-better-auth";
import { auth } from "@/lib/better-auth";
export const providers = [
createAuthBetterAuthProvider(auth),
// ...other providers
];// server/index.ts
import { createNextServer } from "@contract-kit/next";
import { definePorts } from "@contract-kit/ports";
import { providers } from "./providers";
const basePorts = definePorts({
// add your app's other ports here
});
export const server = await createNextServer({
ports: basePorts,
providers,
createContext: async ({ ports }) => ({ ports }),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});4. Use the auth port in beforeHandle hooks
Create a hook to protect routes:
// server/hooks/auth.ts
export const authHook = {
name: "auth",
beforeHandle: async ({ req, ctx }) => {
const user = await ctx.ports.auth.requireUser(req);
return { ctx: { ...ctx, user } };
},
};Then register it on your server:
const server = await createNextServer({
ports: basePorts,
providers: [createAuthBetterAuthProvider(auth)],
hooks: [authHook],
createContext: async ({ ports }) => ({ ports }),
});5. Optional: check auth in use cases
You can also check authentication in use cases:
// use-cases/users/get-profile.ts
import { createUseCase } from "@contract-kit/application";
import { z } from "zod";
const UserProfileSchema = z.object({
id: z.string(),
email: z.string().email(),
});
const useCase = createUseCase<AppCtx>();
export const getUserProfile = useCase
.query("users.profile")
.input(z.object({ userId: z.string() }))
.output(UserProfileSchema)
.run(async ({ ctx, input }) => {
const currentUser = await ctx.ports.auth.requireUser(ctx.req);
return ctx.ports.db.users.getProfile(input.userId);
});Devtools
When @contract-kit/devtools is installed before this provider, auth checks
appear under the dashboard's Auth watcher.
The provider records auth.getSession, auth.getUser, and
auth.requireUser events with the operation, authenticated status, and
duration. User and session objects are not recorded. Provider failures are
recorded with .failed event names and the original error is rethrown.
API reference
AuthPort<User, Session>
The provider implements the auth port interface exported by
@contract-kit/ports:
getSession(req: Request): Promise<AuthSession<User, Session> | null>
Get the current session from a Request. Returns null if not authenticated.
const session = await ctx.ports.auth.getSession(req);
if (session) {
console.log(session.user);
}getUser(req: Request): Promise<User | null>
Get the current user from a Request. Returns null if not authenticated.
This is a convenience method that extracts the user from the session.
const user = await ctx.ports.auth.getUser(req);
if (user) {
console.log(user.email);
}requireUser(req: Request): Promise<User>
Require an authenticated user. Throws an error if not authenticated.
Use this in lifecycle hooks or use cases that require authentication.
const user = await ctx.ports.auth.requireUser(req);
// user is guaranteed to exist hereThrows: AuthUnauthorizedError from @contract-kit/ports if not
authenticated. When this error reaches Contract Kit's server runtime, it is
returned as a framework-owned 401 response with the standard error envelope.
AuthSession<User, Session>
Represents an authenticated session:
interface AuthSession<User = unknown, Session = unknown> {
user: User;
session?: Session;
}createAuthBetterAuthProvider(auth)
Factory function that creates the provider:
function createAuthBetterAuthProvider<User = unknown, Session = unknown>(
auth: BetterAuthServer<User, Session>
): ServiceProviderParameters:
auth: A Better Auth server instance configured in your application
Returns: A Contract Kit provider that can be registered with the server
Advanced usage
Policy-based authorization
You can integrate the auth port with a policy system based on contract metadata:
// Define a contract with auth metadata
const users = createContractGroup();
const getProfile = users
.get("/api/profile")
.responses({
200: z.object({ name: z.string() }),
})
.meta({ auth: "required" });
const ensureAuth = async ({ req, ctx, contract }) => {
if (contract.metadata?.auth !== "required") {
return;
}
const user = await ctx.ports.auth.requireUser(req);
return { ctx: { ...ctx, user } };
};Custom error types
You can wrap requireUser to throw custom error types:
class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
this.name = "UnauthorizedError";
}
}
export const requireAuth = async (
req: Request,
auth: { requireUser: (req: Request) => Promise<unknown> },
) => {
try {
return await auth.requireUser(req);
} catch (error) {
throw new UnauthorizedError();
}
};Multiple authentication strategies
If you need multiple auth strategies (e.g., JWT + session), you can:
- Configure Better Auth with multiple strategies
- Or create multiple providers (e.g.,
createAuthBetterAuthProvider(sessionAuth)+createAuthJWTProvider(jwtAuth))
Better Auth supports multiple strategies out of the box, so the first approach is recommended.
Type safety
The provider maintains full type safety for your custom User type:
type MyUser = {
id: string;
email: string;
role: "admin" | "user";
};
const authProvider = createAuthBetterAuthProvider<MyUser>(auth);
// Later, in your routes:
const user = await ctx.ports.auth.requireUser(req);
// user.role is typed as "admin" | "user"Integration with Better Auth routes
Better Auth provides its own route handlers for login, signup, etc. You can mount these alongside your Contract Kit routes:
// Next.js App Router example
import { auth } from "@/lib/better-auth";
// Better Auth handles /api/auth/*
export const { GET, POST } = auth.handler;
// Your Contract Kit routes handle /api/app/*
// (mounted separately)See the Better Auth documentation for details on route configuration.
Examples
Basic setup
import { betterAuth } from "better-auth";
import { createServer } from "@contract-kit/server";
import { createAuthBetterAuthProvider } from "@contract-kit/provider-auth-better-auth";
const auth = betterAuth({ database: db });
const app = await createServer({
ports: basePorts,
providers: [createAuthBetterAuthProvider(auth)],
createContext: async ({ ports }) => ({ ports }),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});Protecting routes
const authHook = {
name: "auth",
beforeHandle: async ({ req, ctx }) => {
const user = await ctx.ports.auth.requireUser(req);
return { ctx: { ...ctx, user } };
},
};
const server = await createServer({
ports: basePorts,
providers: [createAuthBetterAuthProvider(auth)],
hooks: [authHook],
createContext: async ({ ports }) => ({ ports }),
});Optional authentication
const listData = async ({ req, ctx }) => {
const user = await ctx.ports.auth.getUser(req);
if (user) {
return {
status: 200,
body: { data: getPersonalizedData(user) },
};
}
return {
status: 200,
body: { data: getPublicData() },
};
};License
MIT
