From 8fa968a4c14b153526db11763a6f709a818aa505 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Mon, 23 Mar 2026 13:08:14 +0545 Subject: [PATCH 01/10] feat(poc): update user package to support better-auth --- packages/user/package.json | 8 +- packages/user/src/auth/authProvider.ts | 66 +++ packages/user/src/auth/better-auth/auth.ts | 74 ++++ .../auth/better-auth/betterAuthProvider.ts | 343 ++++++++++++++++ packages/user/src/auth/better-auth/migrate.ts | 40 ++ .../user/src/auth/better-auth/pocRoutes.ts | 260 ++++++++++++ packages/user/src/auth/capabilities.ts | 37 ++ packages/user/src/auth/index.ts | 12 + packages/user/src/plugin.ts | 48 ++- packages/user/src/types/config.ts | 23 +- pnpm-lock.yaml | 383 +++++++++++++++++- 11 files changed, 1278 insertions(+), 16 deletions(-) create mode 100644 packages/user/src/auth/authProvider.ts create mode 100644 packages/user/src/auth/better-auth/auth.ts create mode 100644 packages/user/src/auth/better-auth/betterAuthProvider.ts create mode 100644 packages/user/src/auth/better-auth/migrate.ts create mode 100644 packages/user/src/auth/better-auth/pocRoutes.ts create mode 100644 packages/user/src/auth/capabilities.ts create mode 100644 packages/user/src/auth/index.ts diff --git a/packages/user/package.json b/packages/user/package.json index 73efc146b..89b4c5b6b 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-user.cjs", "module": "./dist/prefabs-tech-fastify-user.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -29,7 +31,10 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { + "better-auth": "1.2.7", "humps": "2.0.1", + "kysely": "^0.27.6", + "pg": "^8.20.0", "validator": "13.15.26" }, "devDependencies": { @@ -43,6 +48,7 @@ "@prefabs.tech/tsconfig": "0.5.0", "@types/humps": "2.0.6", "@types/node": "24.10.13", + "@types/pg": "^8.20.0", "@types/validator": "13.15.10", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", diff --git a/packages/user/src/auth/authProvider.ts b/packages/user/src/auth/authProvider.ts new file mode 100644 index 000000000..3dbe2d0ab --- /dev/null +++ b/packages/user/src/auth/authProvider.ts @@ -0,0 +1,66 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; + +export interface AuthUser { + id: string; + email: string; + emailVerified: boolean; + roles: string[]; + metadata?: Record; +} + +export interface Session { + sessionId: string; + userId: string; + roles: string[]; + expiresAt: Date; +} + +export interface AppError { + code: string; + message: string; + statusCode: number; + cause?: unknown; +} + +export type AuthCapability = "phoneOtp" | "passkey" | "oauth"; + +export interface AuthProvider { + bootstrap(fastify: FastifyInstance): Promise; + + // --- Email + password (universal) --- + signUp(email: string, password: string): Promise; + signIn( + email: string, + password: string, + ): Promise<{ user: AuthUser; token: string }>; + updateEmail(userId: string, newEmail: string): Promise; + updatePassword( + userId: string, + currentPassword: string, + newPassword: string, + ): Promise; + sendVerificationEmail(email: string): Promise; + verifyEmail(token: string): Promise<{ userId: string }>; + sendPasswordResetEmail(email: string): Promise; + resetPassword(token: string, newPassword: string): Promise; + + // --- Session --- + verifySession(req: FastifyRequest): Promise; + signOut(token: string): Promise; + revokeAllSessions(userId: string): Promise; + + // --- User & roles --- + getUser(id: string): Promise; + getUserRoles(userId: string): Promise; + assignRoles(userId: string, roles: string[]): Promise; + removeRoles(userId: string, roles: string[]): Promise; + + // --- Error normalization --- + normalizeError(err: unknown): AppError; + + // --- Capability registry --- + supports(capability: AuthCapability): boolean; + readonly phoneOtp: import("./capabilities").PhoneOtpCapability | undefined; + readonly passkey: import("./capabilities").PasskeyCapability | undefined; + readonly oauth: import("./capabilities").OAuthCapability | undefined; +} diff --git a/packages/user/src/auth/better-auth/auth.ts b/packages/user/src/auth/better-auth/auth.ts new file mode 100644 index 000000000..bd23c9696 --- /dev/null +++ b/packages/user/src/auth/better-auth/auth.ts @@ -0,0 +1,74 @@ +import { betterAuth } from "better-auth"; +import { admin } from "better-auth/plugins/admin"; +import { phoneNumber } from "better-auth/plugins/phone-number"; +import { Kysely, PostgresDialect } from "kysely"; +import { Pool } from "pg"; + +import type { BetterAuthConfig } from "../../types/config"; + +/** + * Creates the Better Auth instance from config. + * + * connectionString is derived from fastify.config.slonik.db via stringifyDsn + * in plugin.ts — the same DB that slonik already connects to. No separate + * DB configuration needed. + * + * Our application queries (getUser, roles) go through slonik (fastify.slonik). + * better-auth manages its own tables internally (user, session, account, verification). + * + * POC notes: + * - sendOTP logs to console — replace with fastify.mailer in Phase 3 + * - sendResetPassword logs to console — same + * - admin plugin required for revokeUserSessions(userId) + * - passkey not in better-auth v1.x — tracked in findings.md + */ +export function createAuth(config: BetterAuthConfig, connectionString: string) { + // Create a Kysely instance with a pg Pool that better-auth will use. + // This satisfies better-auth's expectation for a Kysely adapter. + const pool = new Pool({ connectionString }); + const db = new Kysely({ + dialect: new PostgresDialect({ pool }), + }); + + return betterAuth({ + database: { + db: db, + type: "postgres", + }, + + secret: config.secret, + + trustedOrigins: config.trustedOrigins ?? [], + + emailAndPassword: { + enabled: true, + sendResetPassword: async ({ user, url }) => { + // POC: log to console. Phase 3: use fastify.mailer + console.log( + `[BetterAuth POC] Password reset for ${user.email} → ${url}`, + ); + }, + sendEmailVerificationOnSignUp: false, + }, + + plugins: [ + // POC assumption #4 — phone OTP capability + phoneNumber({ + sendOTP: async ({ phoneNumber: phone, code }) => { + // POC: log to console. Phase 3: use SMS provider via fastify + console.log(`[BetterAuth POC] OTP for ${phone}: ${code}`); + }, + expiresIn: 300, + signUpOnVerification: { + getTempEmail: (phone) => `${phone.replaceAll("+", "")}@phone.local`, + getTempName: (phone) => phone, + }, + }), + + // Required for revokeAllSessions(userId) + admin(), + ], + }); +} + +export type Auth = ReturnType; diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts new file mode 100644 index 000000000..e2b4873e4 --- /dev/null +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -0,0 +1,343 @@ +/* eslint-disable n/no-unsupported-features/node-builtins -- better-auth requires the Fetch API (Headers); supported in Node >=18 */ +import { fromNodeHeaders, toNodeHandler } from "better-auth/node"; +import { sql, stringifyDsn } from "slonik"; + +import { createAuth } from "./auth"; + +import type { BetterAuthConfig } from "../../types/config"; +import type { User } from "../../types/user"; +import type { + AppError, + AuthCapability, + AuthProvider, + AuthUser, + Session, +} from "../authProvider"; +import type { + OAuthCapability, + PasskeyCapability, + PhoneOtpCapability, +} from "../capabilities"; +import type { SlonikOptions } from "@prefabs.tech/fastify-slonik"; +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; + +// fastify.slonik shape — mirrors the declaration in @prefabs.tech/fastify-slonik +// so we don't need a side-effect import of that plugin here. +type SlonikDatabase = { + connect: (connectionRoutine: ConnectionRoutine) => Promise; + pool: DatabasePool; + query: QueryFunction; +}; + +declare module "fastify" { + interface FastifyInstance { + slonik: SlonikDatabase; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function toAuthUser( + user: { id: string; email: string; emailVerified: boolean }, + roles: string[] = [], +): AuthUser { + return { + id: user.id, + email: user.email, + emailVerified: user.emailVerified, + roles, + }; +} + +// --------------------------------------------------------------------------- +// Phone OTP capability +// --------------------------------------------------------------------------- + +class BetterAuthPhoneOtp implements PhoneOtpCapability { + constructor(private readonly auth: ReturnType) {} + + async sendOtp(phoneNumber: string): Promise { + await this.auth.api.sendPhoneNumberOTP({ + body: { phoneNumber }, + headers: new Headers(), + }); + } + + async signInWithOtp(phoneNumber: string, code: string): Promise { + const result = await this.auth.api.verifyPhoneNumber({ + body: { phoneNumber, code }, + headers: new Headers(), + }); + return toAuthUser(result!.user); + } +} + +// --------------------------------------------------------------------------- +// BetterAuthProvider +// --------------------------------------------------------------------------- + +export class BetterAuthProvider implements AuthProvider { + private readonly auth: ReturnType; + private db!: SlonikDatabase; + readonly phoneOtp: PhoneOtpCapability; + readonly passkey: PasskeyCapability | undefined = undefined; + readonly oauth: OAuthCapability | undefined = undefined; + + // verifySessionHandler is the actual preHandler that will be used. + private verifySessionHandler = async ( + req: FastifyRequest, + reply: FastifyReply, + ) => { + const session = await this.verifySession(req); + if (!session) { + reply.code(401); + throw { code: "AUTH_UNAUTHORIZED", message: "Unauthorized" }; + } + const authUser = await this.getUser(session.userId); + if (!authUser) { + reply.code(401); + throw { code: "AUTH_USER_NOT_FOUND", message: "User not found" }; + } + const user: User = { + id: authUser.id, + email: authUser.email, + disabled: false, + lastLoginAt: 0, + signedUpAt: 0, + roles: authUser.roles, + }; + req.user = user; + }; + + constructor(config: BetterAuthConfig, dbConfig: SlonikOptions) { + const connectionString = stringifyDsn(dbConfig.db); + this.auth = createAuth(config, connectionString); + this.phoneOtp = new BetterAuthPhoneOtp(this.auth); + } + + supports(capability: AuthCapability): boolean { + return this[capability] !== undefined; + } + + async bootstrap(fastify: FastifyInstance): Promise { + this.db = fastify.slonik; + + // Decorate fastify with verifySession factory. The factory returns the preHandler. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fastify as any).decorate("verifySession", () => this.verifySessionHandler); + + fastify.all("/api/auth/*", async (req, reply) => { + const handler = toNodeHandler(this.auth); + await handler(req.raw, reply.raw); + return reply; + }); + + fastify.log.info( + "[BetterAuthProvider] bootstrapped — auth routes at /api/auth/*", + ); + } + + async signUp(email: string, password: string): Promise { + const result = await this.auth.api.signUpEmail({ + body: { email, password, name: email }, + }); + return toAuthUser(result.user); + } + + async signIn( + email: string, + password: string, + ): Promise<{ user: AuthUser; token: string }> { + const result = await this.auth.api.signInEmail({ + body: { email, password }, + }); + return { user: toAuthUser(result.user), token: result.token ?? "" }; + } + + async updateEmail(userId: string, newEmail: string): Promise { + await this.auth.api.changeEmail({ + body: { newEmail }, + headers: new Headers(), + }); + } + + async updatePassword( + _userId: string, + currentPassword: string, + newPassword: string, + ): Promise { + await this.auth.api.changePassword({ + body: { currentPassword, newPassword, revokeOtherSessions: true }, + headers: new Headers(), + }); + } + + async sendVerificationEmail(email: string): Promise { + await this.auth.api.sendVerificationEmail({ + body: { email }, + headers: new Headers(), + }); + } + + async verifyEmail(token: string): Promise<{ userId: string }> { + await this.auth.api.verifyEmail({ + query: { token }, + headers: new Headers(), + }); + return { userId: "decoded-from-token" }; + } + + async sendPasswordResetEmail(email: string): Promise { + await this.auth.api.forgetPassword({ + body: { email, redirectTo: "/reset-password" }, + headers: new Headers(), + }); + } + + async resetPassword(token: string, newPassword: string): Promise { + await this.auth.api.resetPassword({ + body: { token, newPassword }, + headers: new Headers(), + }); + } + + async verifySession(req: FastifyRequest): Promise { + const session = await this.auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (!session) return undefined; + + const roles = await this.getUserRoles(session.user.id); + + return { + sessionId: session.session.id, + userId: session.user.id, + roles, + expiresAt: new Date(session.session.expiresAt), + }; + } + + async signOut(token: string): Promise { + await this.auth.api.signOut({ + headers: new Headers({ authorization: `Bearer ${token}` }), + }); + } + + async revokeAllSessions(userId: string): Promise { + await this.auth.api.revokeUserSessions({ + body: { userId }, + headers: new Headers(), + }); + } + + async getUser(id: string): Promise { + return this.db.connect(async (connection) => { + const row = await connection.maybeOne(sql.unsafe` + SELECT id, email, email_verified + FROM "user" + WHERE id = ${id} + `); + + if (!row) return; + + const roles = await this.getUserRoles(id); + + return { + id: row.id as string, + email: row.email as string, + emailVerified: row.email_verified as boolean, + roles, + }; + }); + } + + async getUserRoles(userId: string): Promise { + return this.db.connect(async (connection) => { + const rows = await connection + .many(sql.unsafe`SELECT role FROM user_roles WHERE user_id = ${userId}`) + .catch(() => []); + return rows.map((r) => r.role as string); + }); + } + + async assignRoles(userId: string, roles: string[]): Promise { + await this.db.connect(async (connection) => { + await connection.transaction(async (tx) => { + for (const role of roles) { + await tx.query(sql.unsafe` + INSERT INTO user_roles (user_id, role) + VALUES (${userId}, ${role}) + ON CONFLICT (user_id, role) DO NOTHING + `); + } + }); + }); + } + + async removeRoles(userId: string, roles: string[]): Promise { + await this.db.connect(async (connection) => { + await connection.transaction(async (tx) => { + for (const role of roles) { + await tx.query(sql.unsafe` + DELETE FROM user_roles WHERE user_id = ${userId} AND role = ${role} + `); + } + }); + }); + } + + normalizeError(err: unknown): AppError { + if (err !== null && typeof err === "object" && "status" in err) { + const apiError = err as { + status: number; + body?: { code?: string; message?: string }; + }; + const code = apiError.body?.code ?? ""; + + const codeMap: Record = { + INVALID_EMAIL_OR_PASSWORD: { + code: "AUTH_INVALID_CREDENTIALS", + statusCode: 401, + }, + USER_ALREADY_EXISTS: { code: "AUTH_EMAIL_EXISTS", statusCode: 409 }, + USER_NOT_FOUND: { code: "AUTH_USER_NOT_FOUND", statusCode: 404 }, + SESSION_EXPIRED: { code: "AUTH_SESSION_EXPIRED", statusCode: 401 }, + EMAIL_NOT_VERIFIED: { + code: "AUTH_EMAIL_NOT_VERIFIED", + statusCode: 403, + }, + INVALID_TOKEN: { code: "AUTH_INVALID_TOKEN", statusCode: 401 }, + OTP_EXPIRED: { code: "AUTH_OTP_EXPIRED", statusCode: 401 }, + INVALID_OTP: { code: "AUTH_INVALID_CREDENTIALS", statusCode: 401 }, + }; + + const mapped = codeMap[code]; + + if (mapped) { + return { + ...mapped, + message: apiError.body?.message ?? mapped.code, + cause: err, + }; + } + + return { + code: "AUTH_ERROR", + message: apiError.body?.message ?? "Authentication error", + statusCode: apiError.status ?? 500, + cause: err, + }; + } + + return { + code: "AUTH_UNKNOWN_ERROR", + message: "An unexpected error occurred", + statusCode: 500, + cause: err, + }; + } +} diff --git a/packages/user/src/auth/better-auth/migrate.ts b/packages/user/src/auth/better-auth/migrate.ts new file mode 100644 index 000000000..941712451 --- /dev/null +++ b/packages/user/src/auth/better-auth/migrate.ts @@ -0,0 +1,40 @@ +import { getMigrations } from "better-auth/db"; +import { sql, stringifyDsn } from "slonik"; + +import { createAuth } from "./auth"; + +import type { BetterAuthConfig } from "../../types/config"; +import type { Database, SlonikOptions } from "@prefabs.tech/fastify-slonik"; + +/** + * Run Better Auth migrations + create our own user_roles table via slonik. + * + * Called from plugin.ts on startup when authProvider === "better-auth". + * Safe to call on every startup — all statements are CREATE IF NOT EXISTS. + * + * Better Auth manages its own tables (user, session, account, verification, + * phone_number) using its internal connection. Our user_roles table is + * created via slonik, the same connection pool used by the rest of the app. + */ +export async function runBetterAuthMigrations( + config: BetterAuthConfig, + dbConfig: SlonikOptions, + database: Database, +): Promise { + // Better Auth tables — uses better-auth's own internal pg connection + const connectionString = stringifyDsn(dbConfig.db); + const auth = createAuth(config, connectionString); + const { runMigrations } = await getMigrations(auth.options); + await runMigrations(); + + // user_roles table — provider-agnostic, managed via slonik + await database.connect(async (connection) => { + await connection.query(sql.unsafe` + CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (user_id, role) + ) + `); + }); +} diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts new file mode 100644 index 000000000..276e75e21 --- /dev/null +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -0,0 +1,260 @@ +import FastifyPlugin from "fastify-plugin"; + +import type { BetterAuthProvider } from "./betterAuthProvider"; +import type { FastifyInstance } from "fastify"; + +/** + * POC test routes — registered only when authProvider === "better-auth". + * + * Each route maps directly to one RFC assumption. + * Base URL: /poc/auth/* + * + * These routes are intentionally simple — no validation middleware, no + * GraphQL integration. They exist solely to verify RFC assumptions against + * the real better-auth SDK in the monorepo environment. + * + * Remove or gate behind a feature flag before going to production. + */ +const pocRoutes = async ( + fastify: FastifyInstance, + options: { auth: BetterAuthProvider }, +) => { + const { auth } = options; + + // ------------------------------------------------------------------------- + // RFC assumption #1+2: sign-up, assign default role, return AuthUser + // POST /poc/auth/signup { email, password } + // ------------------------------------------------------------------------- + + fastify.post<{ Body: { email: string; password: string } }>( + "/poc/auth/signup", + async (req, reply) => { + try { + const user = await auth.signUp(req.body.email, req.body.password); + await auth.assignRoles(user.id, ["ROLE_USER"]); + user.roles = await auth.getUserRoles(user.id); + return reply.code(201).send({ ok: true, user }); + } catch (error) { + const appError = auth.normalizeError(error); + return reply.code(appError.statusCode).send({ error: appError }); + } + }, + ); + + // ------------------------------------------------------------------------- + // RFC assumption #2: sign-in → returns user + token + // POST /poc/auth/signin { email, password } + // ------------------------------------------------------------------------- + + fastify.post<{ Body: { email: string; password: string } }>( + "/poc/auth/signin", + async (req, reply) => { + try { + const result = await auth.signIn(req.body.email, req.body.password); + return reply.send({ ok: true, user: result.user, token: result.token }); + } catch (error) { + const appError = auth.normalizeError(error); + return reply.code(appError.statusCode).send({ error: appError }); + } + }, + ); + + // ------------------------------------------------------------------------- + // RFC assumption #3: session verification → Session shape + // GET /poc/auth/me (Authorization: Bearer ) + // ------------------------------------------------------------------------- + + fastify.get("/poc/auth/me", async (req, reply) => { + const session = await auth.verifySession(req); + if (!session) { + return reply.code(401).send({ + error: { code: "AUTH_SESSION_EXPIRED", message: "No valid session" }, + }); + } + const user = await auth.getUser(session.userId); + return reply.send({ ok: true, session, user }); + }); + + // ------------------------------------------------------------------------- + // RFC assumption #3: sign-out + // POST /poc/auth/signout { token } + // ------------------------------------------------------------------------- + + fastify.post<{ Body: { token: string } }>( + "/poc/auth/signout", + async (req, reply) => { + await auth.signOut(req.body.token); + return reply.send({ ok: true }); + }, + ); + + // ------------------------------------------------------------------------- + // RFC assumption #6: role management via user_roles table + // POST /poc/auth/roles/assign { userId, roles } + // POST /poc/auth/roles/remove { userId, roles } + // GET /poc/auth/roles/:userId + // ------------------------------------------------------------------------- + + fastify.post<{ Body: { userId: string; roles: string[] } }>( + "/poc/auth/roles/assign", + async (req, reply) => { + await auth.assignRoles(req.body.userId, req.body.roles); + return reply.send({ + ok: true, + roles: await auth.getUserRoles(req.body.userId), + }); + }, + ); + + fastify.post<{ Body: { userId: string; roles: string[] } }>( + "/poc/auth/roles/remove", + async (req, reply) => { + await auth.removeRoles(req.body.userId, req.body.roles); + return reply.send({ + ok: true, + roles: await auth.getUserRoles(req.body.userId), + }); + }, + ); + + fastify.get<{ Params: { userId: string } }>( + "/poc/auth/roles/:userId", + async (req, reply) => { + return reply.send({ + ok: true, + roles: await auth.getUserRoles(req.params.userId), + }); + }, + ); + + // ------------------------------------------------------------------------- + // RFC assumption #4: phone OTP capability + // POST /poc/auth/otp/send { phoneNumber } + // POST /poc/auth/otp/verify { phoneNumber, otp } + // ------------------------------------------------------------------------- + + fastify.post<{ Body: { phoneNumber: string } }>( + "/poc/auth/otp/send", + async (req, reply) => { + if (!auth.supports("phoneOtp")) { + return reply.code(501).send({ + error: "phoneOtp capability not supported by this provider", + }); + } + try { + await auth.phoneOtp!.sendOtp(req.body.phoneNumber); + return reply.send({ + ok: true, + note: "OTP logged to server console (POC)", + }); + } catch (error) { + return reply + .code(auth.normalizeError(error).statusCode) + .send({ error: auth.normalizeError(error) }); + } + }, + ); + + fastify.post<{ Body: { phoneNumber: string; otp: string } }>( + "/poc/auth/otp/verify", + async (req, reply) => { + if (!auth.supports("phoneOtp")) { + return reply.code(501).send({ + error: "phoneOtp capability not supported by this provider", + }); + } + try { + const user = await auth.phoneOtp!.signInWithOtp( + req.body.phoneNumber, + req.body.otp, + ); + return reply.send({ ok: true, user }); + } catch (error) { + return reply + .code(auth.normalizeError(error).statusCode) + .send({ error: auth.normalizeError(error) }); + } + }, + ); + + // ------------------------------------------------------------------------- + // RFC assumption #5: passkey capability check + // GET /poc/auth/passkey/status + // ------------------------------------------------------------------------- + + fastify.get("/poc/auth/passkey/status", async (_req, reply) => { + return reply.send({ + supported: auth.supports("passkey"), + note: auth.supports("passkey") + ? "Passkey capability is available" + : "Passkey not supported in better-auth v1.x — see poc/better-auth/findings.md", + }); + }); + + // ------------------------------------------------------------------------- + // RFC assumption #7: error normalization — assert correct AppError codes + // POST /poc/auth/error-test/bad-credentials { email } + // POST /poc/auth/error-test/duplicate-signup { email, password } + // ------------------------------------------------------------------------- + + fastify.post<{ Body: { email: string } }>( + "/poc/auth/error-test/bad-credentials", + async (req, reply) => { + try { + await auth.signIn(req.body.email, "definitely-wrong-password-poc"); + return reply.send({ + ok: true, + note: "unexpected success — assertion failed", + }); + } catch (error) { + const appError = auth.normalizeError(error); + return reply.code(appError.statusCode).send({ + appError, + assertions: { + codeIsCorrect: appError.code === "AUTH_INVALID_CREDENTIALS", + statusCodeIsCorrect: appError.statusCode === 401, + }, + }); + } + }, + ); + + fastify.post<{ Body: { email: string; password: string } }>( + "/poc/auth/error-test/duplicate-signup", + async (req, reply) => { + try { + await auth.signUp(req.body.email, req.body.password); + await auth.signUp(req.body.email, req.body.password); + return reply.send({ + ok: true, + note: "unexpected success — assertion failed", + }); + } catch (error) { + const appError = auth.normalizeError(error); + return reply.code(appError.statusCode).send({ + appError, + assertions: { + codeIsCorrect: appError.code === "AUTH_EMAIL_EXISTS", + statusCodeIsCorrect: appError.statusCode === 409, + }, + }); + } + }, + ); + + // ------------------------------------------------------------------------- + // Capability summary + // GET /poc/auth/capabilities + // ------------------------------------------------------------------------- + + fastify.get("/poc/auth/capabilities", async (_req, reply) => { + return reply.send({ + emailPassword: true, + phoneOtp: auth.supports("phoneOtp"), + passkey: auth.supports("passkey"), + oauth: auth.supports("oauth"), + }); + }); +}; + +export default FastifyPlugin(pocRoutes, { name: "poc-better-auth-routes" }); diff --git a/packages/user/src/auth/capabilities.ts b/packages/user/src/auth/capabilities.ts new file mode 100644 index 000000000..432b9ab10 --- /dev/null +++ b/packages/user/src/auth/capabilities.ts @@ -0,0 +1,37 @@ +import type { AuthUser } from "./authProvider"; + +// --------------------------------------------------------------------------- +// Optional capabilities — a provider only implements what it supports. +// Access via auth.phoneOtp, auth.passkey, auth.oauth. +// Check availability with auth.supports("phoneOtp") before calling. +// --------------------------------------------------------------------------- + +export interface PhoneOtpCapability { + sendOtp(phoneNumber: string): Promise; + signInWithOtp(phoneNumber: string, otp: string): Promise; +} + +export interface RegistrationOptions { + challenge: string; + [key: string]: unknown; +} + +export interface AuthenticationOptions { + challenge: string; + [key: string]: unknown; +} + +export interface PasskeyCapability { + startRegistration(userId: string): Promise; + finishRegistration( + userId: string, + response: Record, + ): Promise; + startAuthentication(): Promise; + finishAuthentication(response: Record): Promise; +} + +export interface OAuthCapability { + getRedirectUrl(provider: string, redirectUri: string): Promise; + handleCallback(provider: string, code: string): Promise; +} diff --git a/packages/user/src/auth/index.ts b/packages/user/src/auth/index.ts new file mode 100644 index 000000000..e4ef01ac1 --- /dev/null +++ b/packages/user/src/auth/index.ts @@ -0,0 +1,12 @@ +export type { AuthProvider } from "./authProvider"; +export type { + AuthUser, + Session, + AppError, + AuthCapability, +} from "./authProvider"; +export type { + PhoneOtpCapability, + PasskeyCapability, + OAuthCapability, +} from "./capabilities"; diff --git a/packages/user/src/plugin.ts b/packages/user/src/plugin.ts index ca3623d27..9889f2157 100644 --- a/packages/user/src/plugin.ts +++ b/packages/user/src/plugin.ts @@ -12,16 +12,56 @@ import supertokensPlugin from "./supertokens"; import userContext from "./userContext"; import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { Database } from "@prefabs.tech/fastify-slonik"; import type { FastifyPluginAsync } from "fastify"; const userPlugin: FastifyPluginAsync = async (fastify) => { const { graphql, user } = fastify.config; - await fastify.register(supertokensPlugin); + if (user.authProvider === "better-auth") { + // --- Better Auth path (POC) --- + // Validates config, initializes BetterAuthProvider, registers /api/auth/* + // and POC test routes at /poc/auth/*. + if (!user.betterAuth) { + throw new Error( + '[fastify-user] authProvider is "better-auth" but config.user.betterAuth is missing. ' + + "Provide { secret } in config.user.betterAuth.", + ); + } - fastify.addHook("onReady", async () => { - await seedRoles(user); - }); + // Reuse the same DB configuration that slonik is already connected to — no separate connectionString needed. + const dbConfig = fastify.config.slonik; + + const { BetterAuthProvider } = + await import("./auth/better-auth/betterAuthProvider"); + const { runBetterAuthMigrations } = + await import("./auth/better-auth/migrate"); + const { default: pocRoutes } = await import("./auth/better-auth/pocRoutes"); + + const authProvider = new BetterAuthProvider(user.betterAuth, dbConfig); + + // Run Better Auth migrations + ensure user_roles table exists (via slonik) + const db: Database = fastify.slonik; + await runBetterAuthMigrations(user.betterAuth, dbConfig, db); + + // Register /api/auth/* handler + await authProvider.bootstrap(fastify); + + // Register /poc/auth/* test routes + await fastify.register(pocRoutes, { auth: authProvider }); + + fastify.decorate("authProvider", authProvider); + + // For better-auth, we don't seed roles via SuperTokens; user_roles table is empty. + // Roles will be assigned via API endpoints (e.g., POST /users/:id/roles). + } else { + // --- SuperTokens path (default, unchanged) --- + await fastify.register(supertokensPlugin); + + fastify.addHook("onReady", async () => { + await seedRoles(user); + }); + } await runMigrations(fastify.config, fastify.slonik); diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d98cd658f..5a1deb784 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -10,6 +10,13 @@ import type { StrongPasswordOptions } from "./strongPasswordOptions"; import type { User, UserUpdateInput } from "./user"; import type { FastifyRequest } from "fastify"; +interface BetterAuthConfig { + /** Secret used for signing session tokens (use BETTER_AUTH_SECRET env var) */ + secret: string; + /** Origins allowed to send credentials (passed to better-auth trustedOrigins) */ + trustedOrigins?: string[]; +} + interface EmailOptions { subject?: string; templateName?: string; @@ -113,7 +120,19 @@ interface UserConfig { invitation?: typeof InvitationService; user?: typeof UserService; }; - supertokens: SupertokensConfig; + /** + * Which authentication provider to use. + * @default "supertokens" + */ + authProvider?: "supertokens" | "better-auth"; + /** + * Better Auth configuration — required when authProvider === "better-auth". + */ + betterAuth?: BetterAuthConfig; + /** + * SuperTokens configuration — required when authProvider === "supertokens" (default). + */ + supertokens?: SupertokensConfig; tables?: { invitations?: { name?: string; @@ -124,4 +143,4 @@ interface UserConfig { }; } -export type { EmailOptions, UserConfig }; +export type { BetterAuthConfig, EmailOptions, UserConfig }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 835eb96be..b8a9a57f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,7 +448,7 @@ importers: version: 5.1.0 pg-mem: specifier: 3.0.12 - version: 3.0.12(slonik@46.8.0(zod@3.25.76)) + version: 3.0.12(kysely@0.27.6)(slonik@46.8.0(zod@3.25.76)) prettier: specifier: 3.8.1 version: 3.8.1 @@ -513,9 +513,18 @@ importers: packages/user: dependencies: + better-auth: + specifier: 1.2.7 + version: 1.2.7 humps: specifier: 2.0.1 version: 2.0.1 + kysely: + specifier: ^0.27.6 + version: 0.27.6 + pg: + specifier: ^8.20.0 + version: 8.20.0 validator: specifier: 13.15.26 version: 13.15.26 @@ -550,6 +559,9 @@ importers: '@types/node': specifier: 24.10.13 version: 24.10.13 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/validator': specifier: 13.15.10 version: 13.15.10 @@ -846,6 +858,15 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@better-auth/utils@0.2.4': + resolution: {integrity: sha512-ayiX87Xd5sCHEplAdeMgwkA0FgnXsEZBgDn890XHHwSWNqqRZDYOq3uj2Ei2leTv1I2KbG5HHn60Ah1i2JWZjQ==} + + '@better-auth/utils@0.3.1': + resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@commitlint/cli@20.4.1': resolution: {integrity: sha512-uuFKKpc7OtQM+6SRqT+a4kV818o1pS+uvv/gsRhyX7g4x495jg+Q7P0+O9VNGyLXBYP0syksS7gMRDJKcekr6A==} engines: {node: '>=v18'} @@ -1264,6 +1285,9 @@ packages: engines: {node: '>=6'} hasBin: true + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1453,6 +1477,9 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -1460,6 +1487,13 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/ciphers@0.6.0': + resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1537,6 +1571,43 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1723,6 +1794,13 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} @@ -2073,6 +2151,9 @@ packages: '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -2435,6 +2516,10 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2497,6 +2582,17 @@ packages: before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + better-auth@1.2.7: + resolution: {integrity: sha512-2hCB263GSrgetsMUZw8vv9O1e4S4AlYJW3P4e8bX9u3Q3idv4u9BzDFCblpTLuL4YjYovghMCN0vurAsctXOAQ==} + + better-call@1.3.4: + resolution: {integrity: sha512-ZhY7Wy1usw/YpanMBsvY+cCsdTa6k96iuetRrndvgpFSjl3Bfdqa6DxC6XJf4lzRYqxxtpJiCTjbBkHdSI7hOQ==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2841,6 +2937,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3935,6 +4034,9 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -4035,6 +4137,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -4369,6 +4475,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@0.11.4: + resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==} + engines: {node: ^18.0.0 || >=20.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4598,6 +4708,9 @@ packages: pg-connection-string@2.11.0: resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + pg-cursor@2.15.3: resolution: {integrity: sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==} peerDependencies: @@ -4651,12 +4764,20 @@ packages: peerDependencies: pg: '>=8.0' + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + pg-protocol@1.10.3: resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} pg-protocol@1.11.0: resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-query-stream@4.10.3: resolution: {integrity: sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==} peerDependencies: @@ -4679,6 +4800,15 @@ packages: pg-native: optional: true + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} @@ -4832,6 +4962,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qlobber@8.0.1: resolution: {integrity: sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==} engines: {node: '>= 16'} @@ -4886,6 +5023,9 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4969,6 +5109,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -5044,6 +5187,9 @@ packages: set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5376,9 +5522,16 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + turbo-darwin-64@2.8.7: resolution: {integrity: sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A==} cpu: [x64] @@ -5470,6 +5623,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6379,6 +6535,15 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/utils@0.2.4': + dependencies: + typescript: 5.9.3 + uncrypto: 0.1.3 + + '@better-auth/utils@0.3.1': {} + + '@better-fetch/fetch@1.1.21': {} + '@commitlint/cli@20.4.1(@types/node@24.10.13)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.0 @@ -6860,6 +7025,8 @@ snapshots: yargs: 17.7.2 optional: true + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7037,6 +7204,8 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': optional: true + '@levischuck/tiny-cbor@0.2.11': {} + '@lukeed/ms@2.0.2': {} '@napi-rs/wasm-runtime@0.2.12': @@ -7046,6 +7215,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/ciphers@0.6.0': {} + + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7130,6 +7303,102 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -7287,6 +7556,19 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/x509': 1.14.3 + '@sindresorhus/merge-streams@2.3.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -7322,8 +7604,8 @@ snapshots: '@slonik/sql-tag': 46.8.0(zod@3.25.76) '@slonik/types': 46.8.0(zod@3.25.76) '@slonik/utilities': 46.8.0(zod@3.25.76) - pg: 8.18.0 - pg-query-stream: 4.10.3(pg@8.18.0) + pg: 8.20.0 + pg-query-stream: 4.10.3(pg@8.20.0) pg-types: 4.1.0 postgres-array: 3.0.4 zod: 3.25.76 @@ -7785,6 +8067,12 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.10.13 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -8185,6 +8473,12 @@ snapshots: dependencies: printable-characters: 1.0.42 + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -8241,6 +8535,30 @@ snapshots: before-after-hook@3.0.2: {} + better-auth@1.2.7: + dependencies: + '@better-auth/utils': 0.2.4 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 0.6.0 + '@noble/hashes': 1.8.0 + '@simplewebauthn/browser': 13.3.0 + '@simplewebauthn/server': 13.3.0 + better-call: 1.3.4(zod@3.25.76) + defu: 6.1.4 + jose: 5.10.0 + kysely: 0.27.6 + nanostores: 0.11.4 + zod: 3.25.76 + + better-call@1.3.4(zod@3.25.76): + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.0.1 + optionalDependencies: + zod: 3.25.76 + bignumber.js@9.3.1: {} binary-extensions@2.3.0: {} @@ -8590,6 +8908,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -9985,6 +10305,8 @@ snapshots: jose@4.15.9: {} + jose@5.10.0: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -10118,6 +10440,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.27.6: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -10621,6 +10945,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@0.11.4: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -10842,13 +11168,15 @@ snapshots: pg-connection-string@2.11.0: {} - pg-cursor@2.15.3(pg@8.18.0): + pg-connection-string@2.12.0: {} + + pg-cursor@2.15.3(pg@8.20.0): dependencies: - pg: 8.18.0 + pg: 8.20.0 pg-int8@1.0.1: {} - pg-mem@3.0.12(slonik@46.8.0(zod@3.25.76)): + pg-mem@3.0.12(kysely@0.27.6)(slonik@46.8.0(zod@3.25.76)): dependencies: functional-red-black-tree: 1.0.1 immutable: 4.3.7 @@ -10858,6 +11186,7 @@ snapshots: object-hash: 2.2.0 pgsql-ast-parser: 12.0.2 optionalDependencies: + kysely: 0.27.6 slonik: 46.8.0(zod@3.25.76) pg-numeric@1.0.2: {} @@ -10866,14 +11195,20 @@ snapshots: dependencies: pg: 8.18.0 + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + pg-protocol@1.10.3: {} pg-protocol@1.11.0: {} - pg-query-stream@4.10.3(pg@8.18.0): + pg-protocol@1.13.0: {} + + pg-query-stream@4.10.3(pg@8.20.0): dependencies: - pg: 8.18.0 - pg-cursor: 2.15.3(pg@8.18.0) + pg: 8.20.0 + pg-cursor: 2.15.3(pg@8.20.0) pg-types@2.2.0: dependencies: @@ -10903,6 +11238,16 @@ snapshots: optionalDependencies: pg-cloudflare: 1.3.0 + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + pgpass@1.0.5: dependencies: split2: 4.2.0 @@ -11059,6 +11404,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qlobber@8.0.1: {} qs@6.14.0: @@ -11113,6 +11464,8 @@ snapshots: real-require@0.2.0: {} + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -11220,6 +11573,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 + rou3@0.7.12: {} + run-applescript@7.1.0: {} run-async@4.0.6: {} @@ -11287,6 +11642,8 @@ snapshots: set-cookie-parser@2.7.1: {} + set-cookie-parser@3.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -11695,8 +12052,14 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + turbo-darwin-64@2.8.7: optional: true @@ -11807,6 +12170,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} From 07c4828770670fba991e62230113c28f56767f6f Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Mon, 23 Mar 2026 15:10:48 +0545 Subject: [PATCH 02/10] chore: convert the parsed body to req.raw for betterauth and fix typecheck --- .../src/auth/better-auth/betterAuthProvider.ts | 14 ++++++++++++++ packages/user/src/supertokens/init.ts | 4 ++-- packages/user/src/supertokens/plugin.ts | 4 ++-- .../sendEmailVerificationEmail.ts | 2 +- .../config/emailVerificationRecipeConfig.ts | 4 ++-- .../recipes/config/session/getSession.ts | 2 +- .../recipes/config/session/verifySession.ts | 2 +- .../recipes/config/sessionRecipeConfig.ts | 4 ++-- .../emailPasswordSignUp.ts | 2 +- .../sendPasswordResetEmail.ts | 2 +- .../config/thirdPartyEmailPasswordRecipeConfig.ts | 5 +++-- .../recipes/config/thirdPartyProviders.ts | 2 +- .../recipes/initEmailVerificationRecipe.ts | 2 +- .../src/supertokens/recipes/initSessionRecipe.ts | 2 +- .../recipes/initThirdPartyEmailPasswordRecipe.ts | 2 +- .../src/supertokens/recipes/initUserRolesRecipe.ts | 3 ++- 16 files changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index e2b4873e4..7075c6e43 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -30,6 +30,14 @@ type SlonikDatabase = { query: QueryFunction; }; +// Extend Node's IncomingMessage to include an optional body property +// This is needed because better-auth's better-call expects to read req.raw.body +declare module "http" { + interface IncomingMessage { + body?: unknown; + } +} + declare module "fastify" { interface FastifyInstance { slonik: SlonikDatabase; @@ -130,6 +138,12 @@ export class BetterAuthProvider implements AuthProvider { (fastify as any).decorate("verifySession", () => this.verifySessionHandler); fastify.all("/api/auth/*", async (req, reply) => { + // better-call expects to read the body from req.raw.body if the stream is already consumed. + // Fastify parses the body and sets req.body, but req.raw.body remains undefined. + // Copy the parsed body to req.raw so better-call can use it. + if (req.body !== undefined) { + req.raw.body = req.body; + } const handler = toNodeHandler(this.auth); await handler(req.raw, reply.raw); return reply; diff --git a/packages/user/src/supertokens/init.ts b/packages/user/src/supertokens/init.ts index 15a34cf4f..c2a7a0718 100644 --- a/packages/user/src/supertokens/init.ts +++ b/packages/user/src/supertokens/init.ts @@ -9,7 +9,7 @@ const init = (fastify: FastifyInstance) => { supertokens.init({ appInfo: { - apiBasePath: config.user.supertokens.apiBasePath, + apiBasePath: config.user.supertokens!.apiBasePath, apiDomain: config.baseUrl as string, appName: config.appName as string, websiteDomain: config.appOrigin[0] as string, @@ -17,7 +17,7 @@ const init = (fastify: FastifyInstance) => { framework: "fastify", recipeList: getRecipeList(fastify), supertokens: { - connectionURI: config.user.supertokens.connectionUri as string, + connectionURI: config.user.supertokens!.connectionUri as string, }, }); }; diff --git a/packages/user/src/supertokens/plugin.ts b/packages/user/src/supertokens/plugin.ts index 2458a059b..4d7d3f5af 100644 --- a/packages/user/src/supertokens/plugin.ts +++ b/packages/user/src/supertokens/plugin.ts @@ -14,7 +14,7 @@ const plugin = async (fastify: FastifyInstance) => { init(fastify); - if (config.user.supertokens.setErrorHandler !== false) { + if (config.user.supertokens!.setErrorHandler !== false) { fastify.setErrorHandler(errorHandler); } @@ -27,7 +27,7 @@ const plugin = async (fastify: FastifyInstance) => { // [RL 2024-06-11] change sRefreshToken cookie path from config fastify.addHook("onSend", async (request, reply) => { const refreshTokenCookiePath = - request.server.config.user.supertokens.refreshTokenCookiePath; + request.server.config.user.supertokens!.refreshTokenCookiePath; const setCookieHeader = reply.getHeader("set-cookie"); diff --git a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts index b32b3424b..0d8b55de6 100644 --- a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts +++ b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts @@ -32,7 +32,7 @@ const sendEmailVerificationEmail = ( const emailVerifyLink = input.emailVerifyLink.replace( websiteDomain + "/auth/verify-email", origin + - (fastify.config.user.supertokens.emailVerificationPath || + (fastify.config.user.supertokens!.emailVerificationPath || EMAIL_VERIFICATION_PATH), ); diff --git a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts index 9f686c4bf..9621f4b5d 100644 --- a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts @@ -20,8 +20,8 @@ const getEmailVerificationRecipeConfig = ( let emailVerification: EmailVerificationRecipe = {}; - if (typeof config.user.supertokens.recipes?.emailVerification === "object") { - emailVerification = config.user.supertokens.recipes.emailVerification; + if (typeof config.user.supertokens!.recipes?.emailVerification === "object") { + emailVerification = config.user.supertokens!.recipes.emailVerification; } return { diff --git a/packages/user/src/supertokens/recipes/config/session/getSession.ts b/packages/user/src/supertokens/recipes/config/session/getSession.ts index e5b1def20..313468f4a 100644 --- a/packages/user/src/supertokens/recipes/config/session/getSession.ts +++ b/packages/user/src/supertokens/recipes/config/session/getSession.ts @@ -17,7 +17,7 @@ const getSession = ( .request as FastifyRequest; input.options = { - checkDatabase: config.user.supertokens.checkSessionInDatabase ?? true, + checkDatabase: config.user.supertokens!.checkSessionInDatabase ?? true, ...input.options, }; diff --git a/packages/user/src/supertokens/recipes/config/session/verifySession.ts b/packages/user/src/supertokens/recipes/config/session/verifySession.ts index dc036428e..25486781c 100644 --- a/packages/user/src/supertokens/recipes/config/session/verifySession.ts +++ b/packages/user/src/supertokens/recipes/config/session/verifySession.ts @@ -13,7 +13,7 @@ const verifySession = ( input.verifySessionOptions = { checkDatabase: - fastify.config.user.supertokens.checkSessionInDatabase ?? true, + fastify.config.user.supertokens!.checkSessionInDatabase ?? true, ...input.verifySessionOptions, }; diff --git a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts index 0336391d8..db256e9c4 100644 --- a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts @@ -18,8 +18,8 @@ const getSessionRecipeConfig = ( let session: SessionRecipe = {}; - if (typeof config.user.supertokens.recipes?.session === "object") { - session = config.user.supertokens.recipes.session; + if (typeof config.user.supertokens!.recipes?.session === "object") { + session = config.user.supertokens!.recipes.session; } return { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts index acddb2843..202a8b28a 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts @@ -99,7 +99,7 @@ const emailPasswordSignUp = ( } if ( - config.user.supertokens.sendUserAlreadyExistsWarning && + config.user.supertokens!.sendUserAlreadyExistsWarning && originalResponse.status === "EMAIL_ALREADY_EXISTS_ERROR" ) { try { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts index aba2778a6..adb408fb8 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts @@ -39,7 +39,7 @@ const sendPasswordResetEmail = ( const passwordResetLink = input.passwordResetLink.replace( websiteDomain + "/auth/reset-password", origin + - (fastify.config.user.supertokens.resetPasswordPath || + (fastify.config.user.supertokens!.resetPasswordPath || RESET_PASSWORD_PATH), ); diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts index 69229eff1..d296fe34d 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts @@ -28,10 +28,11 @@ const getThirdPartyEmailPasswordRecipeConfig = ( let thirdPartyEmailPassword: ThirdPartyEmailPasswordRecipe = {}; if ( - typeof config.user.supertokens.recipes?.thirdPartyEmailPassword === "object" + typeof config.user.supertokens!.recipes?.thirdPartyEmailPassword === + "object" ) { thirdPartyEmailPassword = - config.user.supertokens.recipes.thirdPartyEmailPassword; + config.user.supertokens!.recipes.thirdPartyEmailPassword; } return { diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts index d0b5edac6..44ca272bc 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts @@ -5,7 +5,7 @@ import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpasswo const getThirdPartyProviders = (config: ApiConfig) => { const { Apple, Facebook, Github, Google } = ThirdPartyEmailPassword; - const providersConfig = config.user.supertokens.providers; + const providersConfig = config.user.supertokens!.providers; const providers: TypeProvider[] = []; const providerFunctions = [ diff --git a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts index 8fef110a7..89a5bbc28 100644 --- a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts +++ b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts @@ -7,7 +7,7 @@ import type { FastifyInstance } from "fastify"; const init = (fastify: FastifyInstance) => { const emailVerification: SupertokensRecipes["emailVerification"] = - fastify.config.user.supertokens.recipes?.emailVerification; + fastify.config.user.supertokens!.recipes?.emailVerification; if (typeof emailVerification === "function") { return EmailVerification.init(emailVerification(fastify)); diff --git a/packages/user/src/supertokens/recipes/initSessionRecipe.ts b/packages/user/src/supertokens/recipes/initSessionRecipe.ts index 571f51439..f4a84f113 100644 --- a/packages/user/src/supertokens/recipes/initSessionRecipe.ts +++ b/packages/user/src/supertokens/recipes/initSessionRecipe.ts @@ -7,7 +7,7 @@ import type { FastifyInstance } from "fastify"; const init = (fastify: FastifyInstance) => { const session: SupertokensRecipes["session"] = - fastify.config.user.supertokens.recipes?.session; + fastify.config.user.supertokens!.recipes?.session; if (typeof session === "function") { return Session.init(session(fastify)); diff --git a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts index 1434bf53c..6c82fad1a 100644 --- a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts +++ b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts @@ -7,7 +7,7 @@ import type { FastifyInstance } from "fastify"; const init = (fastify: FastifyInstance) => { const thirdPartyEmailPassword: SupertokensRecipes["thirdPartyEmailPassword"] = - fastify.config.user.supertokens.recipes?.thirdPartyEmailPassword; + fastify.config.user.supertokens!.recipes?.thirdPartyEmailPassword; if (typeof thirdPartyEmailPassword === "function") { return ThirdPartyEmailPassword.init(thirdPartyEmailPassword(fastify)); diff --git a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts index b2b1f412c..ae55ecfb0 100644 --- a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts +++ b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts @@ -6,7 +6,8 @@ import type { SupertokensRecipes } from "../types"; import type { FastifyInstance } from "fastify"; const init = (fastify: FastifyInstance) => { - const recipes = fastify.config.user.supertokens.recipes as SupertokensRecipes; + const recipes = fastify.config.user.supertokens! + .recipes as SupertokensRecipes; if (recipes && recipes.userRoles) { return UserRoles.init(recipes.userRoles(fastify)); From 06c55b89559f37a0da8922409a6a11b573da7f81 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Mon, 23 Mar 2026 17:41:32 +0545 Subject: [PATCH 03/10] refactor(poc): update verify otp route --- .../user/src/auth/better-auth/pocRoutes.ts | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index 276e75e21..77f5bf614 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -1,5 +1,6 @@ import FastifyPlugin from "fastify-plugin"; +import type { AppError } from "../authProvider"; import type { BetterAuthProvider } from "./betterAuthProvider"; import type { FastifyInstance } from "fastify"; @@ -163,16 +164,48 @@ const pocRoutes = async ( error: "phoneOtp capability not supported by this provider", }); } + + // Log incoming body for debugging + fastify.log.info({ body: req.body }, "POC OTP verify request"); + + const { phoneNumber, otp } = req.body; + if (!phoneNumber || !otp) { + return reply.code(400).send({ + error: { + code: "MISSING_FIELDS", + message: "Both phoneNumber and otp are required", + received: { phoneNumber, otp }, + }, + }); + } + try { - const user = await auth.phoneOtp!.signInWithOtp( - req.body.phoneNumber, - req.body.otp, - ); + const user = await auth.phoneOtp!.signInWithOtp(phoneNumber, otp); return reply.send({ ok: true, user }); } catch (error) { - return reply - .code(auth.normalizeError(error).statusCode) - .send({ error: auth.normalizeError(error) }); + // Log the raw error for debugging + fastify.log.error({ err: error, phoneNumber }, "POC OTP verify failed"); + let statusCode = 500; + let responseBody: + | { code: string; message: string } + | { error: AppError } = { + code: "AUTH_ERROR", + message: "Authentication error", + }; + try { + const appError: AppError = auth.normalizeError(error); + // Ensure statusCode is a valid number + statusCode = + typeof appError.statusCode === "number" && + appError.statusCode >= 100 && + appError.statusCode < 600 + ? appError.statusCode + : 500; + responseBody = { error: appError }; + } catch (error_) { + fastify.log.error({ err: error_ }, "Failed to normalize error"); + } + return reply.code(statusCode).send(responseBody); } }, ); From 038e14a5f5ebd4d641ca742f18756713a031f76d Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Tue, 24 Mar 2026 12:56:02 +0545 Subject: [PATCH 04/10] chore: add preapare script for monorepo github install --- packages/config/package.json | 1 + packages/error-handler/package.json | 1 + packages/firebase/package.json | 1 + packages/graphql/package.json | 1 + packages/mailer/package.json | 1 + packages/s3/package.json | 1 + packages/slonik/package.json | 1 + packages/swagger/package.json | 1 + packages/user/package.json | 1 + packages/user/src/auth/better-auth/betterAuthProvider.ts | 4 ++-- 10 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/config/package.json b/packages/config/package.json index 8eb7918d5..5e0782993 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -23,6 +23,7 @@ "dist" ], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/error-handler/package.json b/packages/error-handler/package.json index e9a74c251..32a335ff6 100644 --- a/packages/error-handler/package.json +++ b/packages/error-handler/package.json @@ -23,6 +23,7 @@ "dist" ], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/firebase/package.json b/packages/firebase/package.json index b8c1fc10e..a486e1cb0 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -21,6 +21,7 @@ "types": "./dist/types/index.d.ts", "files": ["dist"], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 3e0931a18..64c3d16b7 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -21,6 +21,7 @@ "types": "./dist/types/index.d.ts", "files": ["dist"], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/mailer/package.json b/packages/mailer/package.json index 55e505741..8bbe28273 100644 --- a/packages/mailer/package.json +++ b/packages/mailer/package.json @@ -21,6 +21,7 @@ "types": "./dist/types/index.d.ts", "files": ["dist"], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/s3/package.json b/packages/s3/package.json index 7ef42e238..c22672260 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -21,6 +21,7 @@ "types": "./dist/types/index.d.ts", "files": ["dist"], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/slonik/package.json b/packages/slonik/package.json index 7dfd12652..2094a7d9e 100644 --- a/packages/slonik/package.json +++ b/packages/slonik/package.json @@ -21,6 +21,7 @@ "types": "./dist/types/index.d.ts", "files": ["dist"], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/swagger/package.json b/packages/swagger/package.json index 1c1ea3546..2ce9e26af 100644 --- a/packages/swagger/package.json +++ b/packages/swagger/package.json @@ -23,6 +23,7 @@ "dist" ], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/user/package.json b/packages/user/package.json index 89b4c5b6b..123c81107 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -23,6 +23,7 @@ "dist" ], "scripts": { + "prepare": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 7075c6e43..30d9413bd 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -251,7 +251,7 @@ export class BetterAuthProvider implements AuthProvider { async getUser(id: string): Promise { return this.db.connect(async (connection) => { const row = await connection.maybeOne(sql.unsafe` - SELECT id, email, email_verified + SELECT id, email, emailVerified FROM "user" WHERE id = ${id} `); @@ -263,7 +263,7 @@ export class BetterAuthProvider implements AuthProvider { return { id: row.id as string, email: row.email as string, - emailVerified: row.email_verified as boolean, + emailVerified: row.emailVerified as boolean, roles, }; }); From fda7760cda233fa5bc1daa55d457e41740e67ff3 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Tue, 24 Mar 2026 17:07:34 +0545 Subject: [PATCH 05/10] chore(user): update better auth to support bearer token --- packages/user/src/auth/better-auth/auth.ts | 2 ++ .../auth/better-auth/betterAuthProvider.ts | 8 +++--- .../user/src/auth/better-auth/pocRoutes.ts | 27 ++++++++++++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/user/src/auth/better-auth/auth.ts b/packages/user/src/auth/better-auth/auth.ts index bd23c9696..a949b5d60 100644 --- a/packages/user/src/auth/better-auth/auth.ts +++ b/packages/user/src/auth/better-auth/auth.ts @@ -1,5 +1,6 @@ import { betterAuth } from "better-auth"; import { admin } from "better-auth/plugins/admin"; +import { bearer } from "better-auth/plugins/bearer"; import { phoneNumber } from "better-auth/plugins/phone-number"; import { Kysely, PostgresDialect } from "kysely"; import { Pool } from "pg"; @@ -53,6 +54,7 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { plugins: [ // POC assumption #4 — phone OTP capability + bearer(), phoneNumber({ sendOTP: async ({ phoneNumber: phone, code }) => { // POC: log to console. Phase 3: use SMS provider via fastify diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 30d9413bd..6c480c71d 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -235,9 +235,11 @@ export class BetterAuthProvider implements AuthProvider { }; } - async signOut(token: string): Promise { + async signOut(authorizationHeader: string): Promise { await this.auth.api.signOut({ - headers: new Headers({ authorization: `Bearer ${token}` }), + headers: new Headers({ + authorization: authorizationHeader, + }), }); } @@ -251,7 +253,7 @@ export class BetterAuthProvider implements AuthProvider { async getUser(id: string): Promise { return this.db.connect(async (connection) => { const row = await connection.maybeOne(sql.unsafe` - SELECT id, email, emailVerified + SELECT id, email, ${sql.identifier(["emailVerified"])} FROM "user" WHERE id = ${id} `); diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index 77f5bf614..dfcf92bfb 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -78,16 +78,29 @@ const pocRoutes = async ( // ------------------------------------------------------------------------- // RFC assumption #3: sign-out - // POST /poc/auth/signout { token } + // POST /poc/auth/signout (Authorization: Bearer ) // ------------------------------------------------------------------------- - fastify.post<{ Body: { token: string } }>( - "/poc/auth/signout", - async (req, reply) => { - await auth.signOut(req.body.token); + fastify.post("/poc/auth/signout", async (req, reply) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + return reply.code(401).send({ + error: { + code: "AUTH_UNAUTHORIZED", + message: "Missing Authorization header", + }, + }); + } + + await auth.signOut(authHeader); + return reply.send({ ok: true }); - }, - ); + } catch (error) { + const appError = auth.normalizeError(error); + return reply.code(appError.statusCode).send({ error: appError }); + } + }); // ------------------------------------------------------------------------- // RFC assumption #6: role management via user_roles table From 397ca8caef8a4d435ba727a7ada952cab12e53c7 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Tue, 24 Mar 2026 17:47:23 +0545 Subject: [PATCH 06/10] chore(user): add support to configure router prefix --- packages/user/src/auth/better-auth/auth.ts | 2 ++ packages/user/src/auth/better-auth/betterAuthProvider.ts | 7 +++++-- packages/user/src/types/config.ts | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/user/src/auth/better-auth/auth.ts b/packages/user/src/auth/better-auth/auth.ts index a949b5d60..b77a73ea1 100644 --- a/packages/user/src/auth/better-auth/auth.ts +++ b/packages/user/src/auth/better-auth/auth.ts @@ -37,6 +37,8 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { type: "postgres", }, + basePath: config.routePrefix ?? "/auth", + secret: config.secret, trustedOrigins: config.trustedOrigins ?? [], diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 6c480c71d..6f8e8a091 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -90,6 +90,7 @@ class BetterAuthPhoneOtp implements PhoneOtpCapability { export class BetterAuthProvider implements AuthProvider { private readonly auth: ReturnType; private db!: SlonikDatabase; + private readonly routePrefix: string; readonly phoneOtp: PhoneOtpCapability; readonly passkey: PasskeyCapability | undefined = undefined; readonly oauth: OAuthCapability | undefined = undefined; @@ -122,6 +123,8 @@ export class BetterAuthProvider implements AuthProvider { constructor(config: BetterAuthConfig, dbConfig: SlonikOptions) { const connectionString = stringifyDsn(dbConfig.db); + // Normalize route prefix: remove trailing slashes, default to /auth + this.routePrefix = (config.routePrefix ?? "/auth").replace(/\/+$/, ""); this.auth = createAuth(config, connectionString); this.phoneOtp = new BetterAuthPhoneOtp(this.auth); } @@ -137,7 +140,7 @@ export class BetterAuthProvider implements AuthProvider { // eslint-disable-next-line @typescript-eslint/no-explicit-any (fastify as any).decorate("verifySession", () => this.verifySessionHandler); - fastify.all("/api/auth/*", async (req, reply) => { + fastify.all(this.routePrefix + "/*", async (req, reply) => { // better-call expects to read the body from req.raw.body if the stream is already consumed. // Fastify parses the body and sets req.body, but req.raw.body remains undefined. // Copy the parsed body to req.raw so better-call can use it. @@ -150,7 +153,7 @@ export class BetterAuthProvider implements AuthProvider { }); fastify.log.info( - "[BetterAuthProvider] bootstrapped — auth routes at /api/auth/*", + `[BetterAuthProvider] bootstrapped — auth routes at ${this.routePrefix}/*`, ); } diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index 5a1deb784..e759e6362 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -15,6 +15,7 @@ interface BetterAuthConfig { secret: string; /** Origins allowed to send credentials (passed to better-auth trustedOrigins) */ trustedOrigins?: string[]; + routePrefix?: string; } interface EmailOptions { From ee09d86df0bd09dc9e50253c76f6a74ce26a0ac2 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Wed, 25 Mar 2026 15:49:21 +0545 Subject: [PATCH 07/10] chore(user): fix cors issue for better auth --- .../auth/better-auth/betterAuthProvider.ts | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 6f8e8a091..c640b5d1b 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -44,6 +44,14 @@ declare module "fastify" { } } +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + cors?: { + origin?: string | string[]; + }; + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -140,16 +148,53 @@ export class BetterAuthProvider implements AuthProvider { // eslint-disable-next-line @typescript-eslint/no-explicit-any (fastify as any).decorate("verifySession", () => this.verifySessionHandler); - fastify.all(this.routePrefix + "/*", async (req, reply) => { - // better-call expects to read the body from req.raw.body if the stream is already consumed. - // Fastify parses the body and sets req.body, but req.raw.body remains undefined. - // Copy the parsed body to req.raw so better-call can use it. - if (req.body !== undefined) { - req.raw.body = req.body; - } - const handler = toNodeHandler(this.auth); - await handler(req.raw, reply.raw); - return reply; + const fullRoutePattern = this.routePrefix + "/*"; + fastify.log.info( + `[BetterAuthProvider] Registering auth routes: pattern=${fullRoutePattern}, prefix=${this.routePrefix}`, + ); + + // Register auth routes for all methods EXCEPT OPTIONS (handled by CORS plugin) + fastify.route({ + method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"], + url: fullRoutePattern, + handler: async (req, reply) => { + // DEBUG: log that handler is invoked + fastify.log.info( + { + method: req.method, + url: req.url, + rawUrl: req.raw.url, + routeConfig: req.routeOptions?.config, + }, + "[BetterAuthProvider] Request hit catch-all handler", + ); + + // Because better-call writes directly to reply.raw (Node's ServerResponse), + // we must copy CORS headers from Fastify's reply to raw before invoking better-call. + // The CORS plugin (preHandler) has already added these to reply. + const corsHeaders = [ + "access-control-allow-origin", + "access-control-allow-credentials", + "access-control-expose-headers", + "vary", + ]; + for (const header of corsHeaders) { + const value = reply.getHeader(header); + if (value !== undefined) { + reply.raw.setHeader(header, value); + } + } + + // better-call expects to read the body from req.raw.body if the stream is already consumed. + // Fastify parses the body and sets req.body, but req.raw.body remains undefined. + // Copy the parsed body to req.raw so better-call can use it. + if (req.body !== undefined) { + req.raw.body = req.body; + } + const handler = toNodeHandler(this.auth); + await handler(req.raw, reply.raw); + return reply; + }, }); fastify.log.info( @@ -174,7 +219,7 @@ export class BetterAuthProvider implements AuthProvider { return { user: toAuthUser(result.user), token: result.token ?? "" }; } - async updateEmail(userId: string, newEmail: string): Promise { + async updateEmail(_userId: string, newEmail: string): Promise { await this.auth.api.changeEmail({ body: { newEmail }, headers: new Headers(), From 1eea5c6174c42be33ffbe14a54ffc2bc777c60e7 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Wed, 25 Mar 2026 18:26:26 +0545 Subject: [PATCH 08/10] chore(user): add roles to user signin signup response --- .../user/src/auth/better-auth/betterAuthProvider.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index c640b5d1b..ca151197c 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -206,7 +206,9 @@ export class BetterAuthProvider implements AuthProvider { const result = await this.auth.api.signUpEmail({ body: { email, password, name: email }, }); - return toAuthUser(result.user); + const roles = await this.getUserRoles(result.user.id); + + return toAuthUser(result.user, roles); } async signIn( @@ -216,7 +218,12 @@ export class BetterAuthProvider implements AuthProvider { const result = await this.auth.api.signInEmail({ body: { email, password }, }); - return { user: toAuthUser(result.user), token: result.token ?? "" }; + const roles = await this.getUserRoles(result.user.id); + + return { + user: toAuthUser(result.user, roles), + token: result.token ?? "", + }; } async updateEmail(_userId: string, newEmail: string): Promise { From 41453f56ae31f00cafd8454c98e3c5b12a5ef052 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Thu, 26 Mar 2026 11:00:08 +0545 Subject: [PATCH 09/10] refactor(user): set cookie on login and signin --- .../auth/better-auth/betterAuthProvider.ts | 99 +++++++++++++++++-- .../user/src/auth/better-auth/pocRoutes.ts | 23 ++++- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index ca151197c..76e680cc8 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -42,6 +42,10 @@ declare module "fastify" { interface FastifyInstance { slonik: SlonikDatabase; } + + interface FastifyRequest { + user?: User; + } } declare module "@prefabs.tech/fastify-config" { @@ -144,9 +148,55 @@ export class BetterAuthProvider implements AuthProvider { async bootstrap(fastify: FastifyInstance): Promise { this.db = fastify.slonik; - // Decorate fastify with verifySession factory. The factory returns the preHandler. + // Decorate fastify with verifySession factory (cast to any to bypass type complexities) // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fastify as any).decorate("verifySession", () => this.verifySessionHandler); + fastify.decorate("verifySession", (() => this.verifySessionHandler) as any); + + fastify.addHook("preHandler", async (req: FastifyRequest) => { + // Skip auth routes to avoid interference + const url = req.url || ""; + if (url.startsWith(this.routePrefix)) { + return; + } + + // Skip if Authorization header already exists + if (req.headers.authorization) { + return; + } + + // Try to extract token from our plain cookie (POC routes set this) + const cookieHeader = req.headers.cookie || ""; + const plainCookieName = "better-auth.session_token"; + // Parse cookies by splitting on ';' and trimming + const cookies = cookieHeader.split(";").map((c: string) => c.trim()); + for (const cookie of cookies) { + if (cookie.startsWith(plainCookieName + "=")) { + const token = decodeURIComponent( + cookie.slice(plainCookieName.length + 1), + ); + req.headers.authorization = `Bearer ${token}`; + if (req.raw && typeof req.raw === "object" && "headers" in req.raw) { + (req.raw.headers as Record).authorization = + `Bearer ${token}`; + } + return; + } + } + + // Fallback: try BetterAuth's signed cookie format (if using built-in /auth routes) + const fullSession = await this.auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (fullSession?.session?.token) { + req.headers.authorization = `Bearer ${fullSession.session.token}`; + // Update raw Node.js request headers for downstream middleware + if (req.raw && typeof req.raw === "object" && "headers" in req.raw) { + (req.raw.headers as Record).authorization = + `Bearer ${fullSession.session.token}`; + } + } + }); const fullRoutePattern = this.routePrefix + "/*"; fastify.log.info( @@ -274,20 +324,49 @@ export class BetterAuthProvider implements AuthProvider { } async verifySession(req: FastifyRequest): Promise { + // First try BetterAuth's cookie-based session const session = await this.auth.api.getSession({ headers: fromNodeHeaders(req.headers), }); - if (!session) return undefined; + if (session) { + const roles = await this.getUserRoles(session.user.id); + return { + sessionId: session.session.id, + userId: session.user.id, + roles, + expiresAt: new Date(session.session.expiresAt), + }; + } + + // Fallback: check Authorization header with Bearer token + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + // Look up session by token in better-auth's session table + const dbSession = await this.db.connect(async (connection) => { + const row = await connection.maybeOne(sql.unsafe` + SELECT id, "userId", "expiresAt" + FROM "session" + WHERE token = ${token} AND "expiresAt" > NOW() + `); + return row as + | { id: string; userId: string; expiresAt: string } + | undefined; + }); - const roles = await this.getUserRoles(session.user.id); + if (dbSession) { + const roles = await this.getUserRoles(dbSession.userId); + return { + sessionId: dbSession.id, + userId: dbSession.userId, + roles, + expiresAt: new Date(dbSession.expiresAt), + }; + } + } - return { - sessionId: session.session.id, - userId: session.user.id, - roles, - expiresAt: new Date(session.session.expiresAt), - }; + return undefined; } async signOut(authorizationHeader: string): Promise { diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index dfcf92bfb..a47407636 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -31,10 +31,19 @@ const pocRoutes = async ( "/poc/auth/signup", async (req, reply) => { try { + // Create user const user = await auth.signUp(req.body.email, req.body.password); + // Assign default role await auth.assignRoles(user.id, ["ROLE_USER"]); - user.roles = await auth.getUserRoles(user.id); - return reply.code(201).send({ ok: true, user }); + // Sign in to get token (so we can set cookie and return token) + const result = await auth.signIn(req.body.email, req.body.password); + // Set session cookie + const maxAge = 7 * 24 * 60 * 60; // 7 days in seconds + const cookie = `better-auth.session_token=${encodeURIComponent(result.token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`; + reply.header("Set-Cookie", cookie); + return reply + .code(201) + .send({ ok: true, user: result.user, token: result.token }); } catch (error) { const appError = auth.normalizeError(error); return reply.code(appError.statusCode).send({ error: appError }); @@ -52,6 +61,10 @@ const pocRoutes = async ( async (req, reply) => { try { const result = await auth.signIn(req.body.email, req.body.password); + // Set session cookie + const maxAge = 7 * 24 * 60 * 60; // 7 days in seconds + const cookie = `better-auth.session_token=${encodeURIComponent(result.token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`; + reply.header("Set-Cookie", cookie); return reply.send({ ok: true, user: result.user, token: result.token }); } catch (error) { const appError = auth.normalizeError(error); @@ -95,6 +108,12 @@ const pocRoutes = async ( await auth.signOut(authHeader); + // Clear session cookie + reply.header( + "Set-Cookie", + "better-auth.session_token=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + ); + return reply.send({ ok: true }); } catch (error) { const appError = auth.normalizeError(error); From cfea97679d17f9899161b627f6dca51297d3c178 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Fri, 27 Mar 2026 17:45:05 +0545 Subject: [PATCH 10/10] refactor(poc): update cookie based sigin signup methods --- packages/user/src/auth/authProvider.ts | 7 +++- .../auth/better-auth/betterAuthProvider.ts | 39 +++++++++++++------ .../user/src/auth/better-auth/pocRoutes.ts | 35 +++++------------ 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/user/src/auth/authProvider.ts b/packages/user/src/auth/authProvider.ts index 3dbe2d0ab..9bc278b9b 100644 --- a/packages/user/src/auth/authProvider.ts +++ b/packages/user/src/auth/authProvider.ts @@ -28,7 +28,10 @@ export interface AuthProvider { bootstrap(fastify: FastifyInstance): Promise; // --- Email + password (universal) --- - signUp(email: string, password: string): Promise; + signUp( + email: string, + password: string, + ): Promise<{ user: AuthUser; token: string }>; signIn( email: string, password: string, @@ -46,7 +49,7 @@ export interface AuthProvider { // --- Session --- verifySession(req: FastifyRequest): Promise; - signOut(token: string): Promise; + signOut(req: FastifyRequest): Promise; revokeAllSessions(userId: string): Promise; // --- User & roles --- diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 76e680cc8..afb2e9763 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -3,6 +3,7 @@ import { fromNodeHeaders, toNodeHandler } from "better-auth/node"; import { sql, stringifyDsn } from "slonik"; import { createAuth } from "./auth"; +import { ROLE_USER } from "../../constants"; import type { BetterAuthConfig } from "../../types/config"; import type { User } from "../../types/user"; @@ -252,27 +253,40 @@ export class BetterAuthProvider implements AuthProvider { ); } - async signUp(email: string, password: string): Promise { + async signUp( + email: string, + password: string, + ): Promise<{ user: AuthUser; token: string; headers: Headers }> { const result = await this.auth.api.signUpEmail({ body: { email, password, name: email }, + returnHeaders: true, }); - const roles = await this.getUserRoles(result.user.id); - return toAuthUser(result.user, roles); + await this.assignRoles(result.response.user.id, [ROLE_USER]); + const roles = await this.getUserRoles(result.response.user.id); + + return { + user: toAuthUser(result.response.user, roles), + token: result.response.token ?? "", + headers: result.headers, + }; } async signIn( email: string, password: string, - ): Promise<{ user: AuthUser; token: string }> { + ): Promise<{ user: AuthUser; token: string; headers: Headers }> { const result = await this.auth.api.signInEmail({ body: { email, password }, + returnHeaders: true, }); - const roles = await this.getUserRoles(result.user.id); + + const roles = await this.getUserRoles(result.response.user.id); return { - user: toAuthUser(result.user, roles), - token: result.token ?? "", + user: toAuthUser(result.response.user, roles), + token: result.response.token ?? "", + headers: result.headers, }; } @@ -369,12 +383,13 @@ export class BetterAuthProvider implements AuthProvider { return undefined; } - async signOut(authorizationHeader: string): Promise { - await this.auth.api.signOut({ - headers: new Headers({ - authorization: authorizationHeader, - }), + async signOut(req: FastifyRequest) { + const result = await this.auth.api.signOut({ + headers: fromNodeHeaders(req.headers), // passes cookie automatically + returnHeaders: true, }); + + return result; } async revokeAllSessions(userId: string): Promise { diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index a47407636..f80575750 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -32,14 +32,9 @@ const pocRoutes = async ( async (req, reply) => { try { // Create user - const user = await auth.signUp(req.body.email, req.body.password); - // Assign default role - await auth.assignRoles(user.id, ["ROLE_USER"]); - // Sign in to get token (so we can set cookie and return token) - const result = await auth.signIn(req.body.email, req.body.password); - // Set session cookie - const maxAge = 7 * 24 * 60 * 60; // 7 days in seconds - const cookie = `better-auth.session_token=${encodeURIComponent(result.token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`; + const result = await auth.signUp(req.body.email, req.body.password); + + const cookie = result.headers.get("set-cookie"); reply.header("Set-Cookie", cookie); return reply .code(201) @@ -61,10 +56,11 @@ const pocRoutes = async ( async (req, reply) => { try { const result = await auth.signIn(req.body.email, req.body.password); + // Set session cookie - const maxAge = 7 * 24 * 60 * 60; // 7 days in seconds - const cookie = `better-auth.session_token=${encodeURIComponent(result.token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`; + const cookie = result.headers.get("set-cookie"); reply.header("Set-Cookie", cookie); + return reply.send({ ok: true, user: result.user, token: result.token }); } catch (error) { const appError = auth.normalizeError(error); @@ -96,23 +92,10 @@ const pocRoutes = async ( fastify.post("/poc/auth/signout", async (req, reply) => { try { - const authHeader = req.headers.authorization; - if (!authHeader) { - return reply.code(401).send({ - error: { - code: "AUTH_UNAUTHORIZED", - message: "Missing Authorization header", - }, - }); - } - - await auth.signOut(authHeader); + const result = await auth.signOut(req); - // Clear session cookie - reply.header( - "Set-Cookie", - "better-auth.session_token=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", - ); + const cookie = result.headers.get("set-cookie"); + reply.header("Set-Cookie", cookie); return reply.send({ ok: true }); } catch (error) {