diff --git a/ROADMAP.md b/ROADMAP.md index e1f6b4c9a..8afd3f5f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1305,7 +1305,7 @@ Plugin architecture refactoring to support true modular development, plugin isol **Fix:** 1. Added `AuthPlugin` from `@objectstack/plugin-auth` to `objectstack.config.ts` for server mode (`pnpm dev:server`). 2. Created `authHandlers.ts` with in-memory mock implementations of better-auth endpoints for MSW mode (`pnpm dev`). Mock handlers are added to `customHandlers` in both `browser.ts` and `server.ts`. -3. Mock handlers support: sign-up/email, sign-in/email, get-session, sign-out, forgot-password, reset-password, update-user. +3. Mock handlers support: sign-up/email, sign-in/email, get-session, sign-out, forget-password (better-auth convention), reset-password, update-user. **Tests:** 11 new auth handler tests, all existing MSW (7) and auth (24) tests pass. diff --git a/apps/console/src/__tests__/authHandlers.test.ts b/apps/console/src/__tests__/authHandlers.test.ts index 89ce49de9..fa9039295 100644 --- a/apps/console/src/__tests__/authHandlers.test.ts +++ b/apps/console/src/__tests__/authHandlers.test.ts @@ -143,8 +143,8 @@ describe('Mock Auth Handlers', () => { expect(sessionRes.status).toBe(401); }); - it('should handle forgot-password', async () => { - const res = await fetch(`${BASE_URL}/forgot-password`, { + it('should handle forget-password', async () => { + const res = await fetch(`${BASE_URL}/forget-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'alice@example.com' }), diff --git a/apps/console/src/mocks/authHandlers.ts b/apps/console/src/mocks/authHandlers.ts index 88b8e654a..75e6b6150 100644 --- a/apps/console/src/mocks/authHandlers.ts +++ b/apps/console/src/mocks/authHandlers.ts @@ -14,7 +14,7 @@ * POST /sign-in/email — authenticate with email + password * GET /get-session — retrieve the current session * POST /sign-out — clear the session - * POST /forgot-password — no-op acknowledgement + * POST /forget-password — no-op acknowledgement (better-auth convention) * POST /reset-password — no-op acknowledgement * POST /update-user — update the current user's profile */ @@ -163,8 +163,9 @@ export function createAuthHandlers(baseUrl: string): HttpHandler[] { }), // ── Forgot Password (mock acknowledgement) ────────────────────────── - http.post(`${p}/forgot-password`, () => { - return HttpResponse.json({ success: true }); + // better-auth uses "forget-password" (not "forgot-password") + http.post(`${p}/forget-password`, () => { + return HttpResponse.json({ status: true }); }), // ── Reset Password (mock acknowledgement) ──────────────────────────── diff --git a/packages/auth/README.md b/packages/auth/README.md index 38f9c3100..f8ca7fade 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -8,7 +8,7 @@ Authentication system for Object UI — AuthProvider, guards, login/register for - 🛡️ **AuthGuard** - Protect routes and components from unauthenticated access - 📝 **Pre-built Forms** - LoginForm, RegisterForm, and ForgotPasswordForm ready to use - 👤 **UserMenu** - Display authenticated user info with sign-out support -- 🔑 **Auth Client Factory** - `createAuthClient` for pluggable backend integration +- 🔑 **Auth Client Factory** - `createAuthClient` powered by official [better-auth](https://better-auth.com) client - 🌐 **Authenticated Fetch** - `createAuthenticatedFetch` for automatic token injection - 👀 **Preview Mode** - Auto-login with simulated identity for marketplace demos and app showcases - 🎯 **Type-Safe** - Full TypeScript support with exported types @@ -30,8 +30,7 @@ import { AuthProvider, useAuth, AuthGuard } from '@object-ui/auth'; import { createAuthClient } from '@object-ui/auth'; const authClient = createAuthClient({ - provider: 'custom', - apiUrl: 'https://api.example.com/auth', + baseURL: 'https://api.example.com/auth', }); function App() { diff --git a/packages/auth/package.json b/packages/auth/package.json index b20a05a0f..c59cd5f7e 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -34,7 +34,8 @@ "react": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@object-ui/types": "workspace:*" + "@object-ui/types": "workspace:*", + "better-auth": "^1.5.4" }, "devDependencies": { "@types/react": "19.2.14", diff --git a/packages/auth/src/__tests__/createAuthClient.test.ts b/packages/auth/src/__tests__/createAuthClient.test.ts index 8b81a0abd..bcfe30405 100644 --- a/packages/auth/src/__tests__/createAuthClient.test.ts +++ b/packages/auth/src/__tests__/createAuthClient.test.ts @@ -1,21 +1,47 @@ /** - * Tests for createAuthClient + * Tests for createAuthClient (backed by official better-auth client) */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createAuthClient } from '../createAuthClient'; import type { AuthClient } from '../types'; -describe('createAuthClient', () => { - let client: AuthClient; - let mockFetch: ReturnType; - - beforeEach(() => { - mockFetch = vi.fn(); - client = createAuthClient({ baseURL: '/api/auth', fetchFn: mockFetch }); +/** + * Helper: creates a mock fetch that routes requests based on URL + * and records every call for inspection. + */ +function createMockFetch(handlers: Record) { + const calls: Array<{ url: string; method: string; body: string | null }> = []; + const mockFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + let url: string; + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else { + url = input.url; + } + calls.push({ url, method: init?.method ?? 'GET', body: init?.body as string | null }); + for (const [pattern, handler] of Object.entries(handlers)) { + if (url.includes(pattern)) { + return new Response(JSON.stringify(handler.body), { + status: handler.status ?? 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + return new Response(JSON.stringify({ message: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); }); + return { mockFn, calls }; +} +describe('createAuthClient', () => { it('creates a client with all expected methods', () => { + const { mockFn } = createMockFetch({}); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); expect(client).toHaveProperty('signIn'); expect(client).toHaveProperty('signUp'); expect(client).toHaveProperty('signOut'); @@ -26,156 +52,150 @@ describe('createAuthClient', () => { }); it('signIn sends POST to /sign-in/email', async () => { - const mockResponse = { - user: { id: '1', name: 'Test', email: 'test@test.com' }, - session: { token: 'tok123' }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResponse), + const { mockFn, calls } = createMockFetch({ + '/sign-in/email': { + body: { + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok123', id: 's1', userId: '1', expiresAt: '2025-01-01' }, + }, + }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); const result = await client.signIn({ email: 'test@test.com', password: 'pass123' }); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/sign-in/email', - expect.objectContaining({ - method: 'POST', - credentials: 'include', - body: JSON.stringify({ email: 'test@test.com', password: 'pass123' }), - }), - ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/sign-in/email'); + expect(calls[0].method).toBe('POST'); + expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com', password: 'pass123' }); expect(result.user.email).toBe('test@test.com'); expect(result.session.token).toBe('tok123'); }); it('signUp sends POST to /sign-up/email', async () => { - const mockResponse = { - user: { id: '2', name: 'New User', email: 'new@test.com' }, - session: { token: 'tok456' }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResponse), + const { mockFn, calls } = createMockFetch({ + '/sign-up/email': { + body: { + user: { id: '2', name: 'New User', email: 'new@test.com' }, + session: { token: 'tok456', id: 's2', userId: '2', expiresAt: '2025-01-01' }, + }, + }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); const result = await client.signUp({ name: 'New User', email: 'new@test.com', password: 'pass123' }); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/sign-up/email', - expect.objectContaining({ method: 'POST' }), - ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/sign-up/email'); + expect(calls[0].method).toBe('POST'); + expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'new@test.com', name: 'New User' }); expect(result.user.name).toBe('New User'); }); it('signOut sends POST to /sign-out', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), + const { mockFn, calls } = createMockFetch({ + '/sign-out': { body: { success: true } }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); await client.signOut(); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/sign-out', - expect.objectContaining({ method: 'POST' }), - ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/sign-out'); + expect(calls[0].method).toBe('POST'); }); it('getSession sends GET to /get-session', async () => { - const mockSession = { - user: { id: '1', name: 'Test', email: 'test@test.com' }, - session: { token: 'tok789' }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSession), + const { mockFn, calls } = createMockFetch({ + '/get-session': { + body: { + user: { id: '1', name: 'Test', email: 'test@test.com' }, + session: { token: 'tok789', id: 's1', userId: '1', expiresAt: '2025-01-01' }, + }, + }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); const result = await client.getSession(); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/get-session', - expect.objectContaining({ method: 'GET' }), - ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/get-session'); + expect(calls[0].method).toBe('GET'); expect(result?.user.id).toBe('1'); }); it('getSession returns null on failure', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); + const { mockFn } = createMockFetch({ + '/get-session': { status: 401, body: { message: 'Unauthorized' } }, + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); const result = await client.getSession(); expect(result).toBeNull(); }); - it('forgotPassword sends POST to /forgot-password', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), + it('forgotPassword sends POST to /forget-password', async () => { + const { mockFn, calls } = createMockFetch({ + '/forget-password': { body: { status: true } }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); await client.forgotPassword('test@test.com'); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/forgot-password', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ email: 'test@test.com' }), - }), - ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/forget-password'); + expect(calls[0].method).toBe('POST'); + expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com' }); }); it('resetPassword sends POST to /reset-password', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), + const { mockFn, calls } = createMockFetch({ + '/reset-password': { body: { status: true } }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); await client.resetPassword('token123', 'newpass'); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/reset-password', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ token: 'token123', newPassword: 'newpass' }), - }), - ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/reset-password'); + expect(calls[0].method).toBe('POST'); + expect(JSON.parse(calls[0].body!)).toMatchObject({ token: 'token123', newPassword: 'newpass' }); }); it('throws error with server message on non-OK response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - json: () => Promise.resolve({ message: 'Invalid credentials' }), + const { mockFn } = createMockFetch({ + '/sign-in/email': { + status: 401, + body: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' }, + }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow('Invalid credentials'); }); - it('throws generic error when response has no message', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.reject(new Error('parse error')), + it('throws error on non-OK response without message', async () => { + const { mockFn } = createMockFetch({ + '/sign-in/email': { status: 500, body: {} }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); - await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow( - 'Auth request failed with status 500', - ); + await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow(); }); it('updateUser sends POST to /update-user and returns user', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ user: { id: '1', name: 'Updated', email: 'test@test.com' } }), + const { mockFn, calls } = createMockFetch({ + '/update-user': { + body: { user: { id: '1', name: 'Updated', email: 'test@test.com' } }, + }, }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); const result = await client.updateUser({ name: 'Updated' }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/auth/update-user'); + expect(calls[0].method).toBe('POST'); expect(result.name).toBe('Updated'); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/update-user', - expect.objectContaining({ method: 'POST' }), - ); }); }); diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index 257d5c142..39be56747 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -6,14 +6,47 @@ * LICENSE file in the root directory of this source tree. */ +import { createAuthClient as createBetterAuthClient } from 'better-auth/client'; import type { AuthClient, AuthClientConfig, AuthUser, AuthSession, SignInCredentials, SignUpData } from './types'; /** - * Create an auth client instance. + * Resolve a baseURL (which may be relative or absolute) into the + * `{ origin, basePath }` pair required by the better-auth client. * - * This factory creates an abstraction layer over the authentication provider. - * It is designed to work with better-auth but can be adapted to any auth backend - * that exposes standard REST endpoints for sign-in, sign-up, sign-out, and session management. + * - Absolute URLs (e.g. `http://localhost:3000/api/auth`) are split into origin + pathname. + * - Relative paths (e.g. `/api/v1/auth`) use `window.location.origin` in + * browser environments, falling back to `http://localhost` elsewhere. + */ +function resolveAuthURL(baseURL: string): { origin: string; basePath: string } { + try { + const url = new URL(baseURL); + return { origin: url.origin, basePath: url.pathname.replace(/\/$/, '') }; + } catch { + // Relative URL – resolve against the current origin when available + const origin = getWindowOrigin() ?? 'http://localhost'; + return { origin, basePath: baseURL.replace(/\/$/, '') }; + } +} + +/** Safely read window.location.origin when available (browser environments). */ +function getWindowOrigin(): string | undefined { + try { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + } catch { + // window may be defined but accessing location can throw in some SSR environments + } + return undefined; +} + +/** + * Create an auth client instance backed by the official better-auth client. + * + * Internally delegates to `createAuthClient` from `better-auth/client`, + * exposing the same {@link AuthClient} interface so that AuthProvider, + * createAuthenticatedFetch, and all downstream consumers continue to work + * without changes. * * @example * ```ts @@ -22,79 +55,91 @@ import type { AuthClient, AuthClientConfig, AuthUser, AuthSession, SignInCredent * ``` */ export function createAuthClient(config: AuthClientConfig): AuthClient { - const { baseURL, fetchFn = fetch } = config; - - async function request(path: string, options?: RequestInit): Promise { - const url = `${baseURL}${path}`; - const response = await fetchFn(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - credentials: 'include', - }); + const { baseURL, fetchFn } = config; + const { origin, basePath } = resolveAuthURL(baseURL); - if (!response.ok) { - const body = await response.json().catch(() => null); - const message = (body && typeof body === 'object' && 'message' in body) - ? String(body.message) - : `Auth request failed with status ${response.status}`; - throw new Error(message); - } + const betterAuth = createBetterAuthClient({ + baseURL: origin, + basePath, + disableDefaultFetchPlugins: true, + fetchOptions: fetchFn ? { customFetchImpl: fetchFn } : undefined, + }); - return response.json(); - } + // The better-auth client exposes methods whose TS return types are narrower + // than the runtime JSON the server actually sends (e.g. `session` on signIn). + // We deliberately cast through `unknown` to bridge from better-auth types + // to the ObjectUI AuthClient contract. return { async signIn(credentials: SignInCredentials) { - return request<{ user: AuthUser; session: AuthSession }>('/sign-in/email', { - method: 'POST', - body: JSON.stringify(credentials), + const { data, error } = await betterAuth.signIn.email({ + email: credentials.email, + password: credentials.password, }); + if (error) { + throw new Error(error.message ?? `Auth request failed with status ${error.status}`); + } + const payload = data as unknown as { user: AuthUser; session: AuthSession }; + return { user: payload.user, session: payload.session }; }, - async signUp(data: SignUpData) { - return request<{ user: AuthUser; session: AuthSession }>('/sign-up/email', { - method: 'POST', - body: JSON.stringify(data), + async signUp(signUpData: SignUpData) { + const { data, error } = await betterAuth.signUp.email({ + email: signUpData.email, + password: signUpData.password, + name: signUpData.name, }); + if (error) { + throw new Error(error.message ?? `Auth request failed with status ${error.status}`); + } + const payload = data as unknown as { user: AuthUser; session: AuthSession }; + return { user: payload.user, session: payload.session }; }, async signOut() { - await request('/sign-out', { method: 'POST' }); + const { error } = await betterAuth.signOut(); + if (error) { + throw new Error(error.message ?? `Auth request failed with status ${error.status}`); + } }, async getSession() { - try { - return await request<{ user: AuthUser; session: AuthSession }>('/get-session', { - method: 'GET', - }); - } catch { - return null; - } + const { data, error } = await betterAuth.getSession(); + if (error || !data) return null; + const payload = data as unknown as { user: AuthUser; session: AuthSession }; + return { user: payload.user, session: payload.session }; }, async forgotPassword(email: string) { - await request('/forgot-password', { - method: 'POST', - body: JSON.stringify({ email }), - }); + // better-auth uses "forgetPassword" (without the "o"); the method + // exists at runtime but is not present in the default TS types. + type ForgetPasswordFn = (opts: { email: string; redirectTo: string }) => + Promise<{ error: { message?: string; status: number } | null }>; + const forgetPw = (betterAuth as unknown as { forgetPassword: ForgetPasswordFn }).forgetPassword; + const { error } = await forgetPw({ email, redirectTo: '/' }); + if (error) { + throw new Error(error.message ?? `Auth request failed with status ${error.status}`); + } }, async resetPassword(token: string, newPassword: string) { - await request('/reset-password', { - method: 'POST', - body: JSON.stringify({ token, newPassword }), - }); + const { error } = await betterAuth.resetPassword({ token, newPassword }); + if (error) { + throw new Error(error.message ?? `Auth request failed with status ${error.status}`); + } }, - async updateUser(data: Partial) { - const result = await request<{ user: AuthUser }>('/update-user', { - method: 'POST', - body: JSON.stringify(data), - }); - return result.user; + async updateUser(userData: Partial) { + const { data, error } = await betterAuth.updateUser(userData); + if (error) { + throw new Error(error.message ?? `Auth request failed with status ${error.status}`); + } + if (!data) { + throw new Error('Update user returned no data'); + } + // The server response may wrap the user in a `user` key or return it directly + const raw = data as unknown as Record; + return (raw && typeof raw === 'object' && 'user' in raw ? raw.user : raw) as AuthUser; }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2146aab79..132599d0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + better-auth: + specifier: ^1.5.4 + version: 1.5.4(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(mongodb@7.1.0)(mysql2@3.15.3)(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18) devDependencies: '@types/react': specifier: 19.2.14