From e340431de863e95f877a463d4575177ebbbff0fd Mon Sep 17 00:00:00 2001 From: Shridhar Deshmukh Date: Tue, 20 Oct 2026 21:10:17 +0000 Subject: [PATCH 1/5] feat(auth): add phone number methods to createNeonAuth Adds signIn.phoneNumber and phoneNumber.{sendOtp,verify,requestPasswordReset,resetPassword} to the server endpoint tree. Paths match better-auth 1.7.6 plugins/phone-number/routes.mjs. Session cookies already flow through the existing fetchWithAuth minting path. Co-authored-by: Isaac --- CLAUDE.md | 2 +- packages/auth/CHANGELOG.md | 1 + packages/auth/NEXT-JS.md | 3 +- .../auth/src/server/client-factory.test.ts | 165 ++++++++++++++++++ packages/auth/src/server/endpoints.ts | 9 + 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ddf68680..dabe4a1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -580,7 +580,7 @@ import { createAuthClient } from '@neondatabase/auth/next'; export const authClient = createAuthClient(); // Available APIs: authClient.getSession(), signIn, signUp, signOut, -// organization.*, admin.*, emailOtp.*, oauth2.* (OAuth provider login/consent pages), updateUser, etc. +// organization.*, admin.*, emailOtp.*, phoneNumber.*, oauth2.* (OAuth provider login/consent pages), updateUser, etc. ``` ### Using Auth UI Components diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index 54c4e0d8..b2f013bb 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **OAuth provider client plugin**: every client (`createAuthClient()` and the Next.js `createAuthClient()`) now includes the Better Auth OAuth provider client (`@better-auth/oauth-provider`, pinned to `1.7.6`) for the login and consent pages of a Neon Auth project acting as an OAuth provider. The client exposes `oauth2.*`; `createNeonAuth()` and `createAuthServer()` expose the session-cookie user actions that Neon routes under `oauth2`: `consent`, `continue`, `publicClient`, `publicClientPrelogin`, `getConsent`, `getConsents`, `updateConsent` and `deleteConsent`. Client management is rejected by Neon for end users. +- **Phone number methods on `createNeonAuth()`**: The server-side auth object now exposes **`signIn.phoneNumber()`** and **`phoneNumber.sendOtp()`**, **`phoneNumber.verify()`**, **`phoneNumber.requestPasswordReset()`** and **`phoneNumber.resetPassword()`**. `signIn.phoneNumber()` and `phoneNumber.verify()` set the session cookies the same way as `signIn.email()`. Requires the phone number plugin to be enabled on the Neon Auth project. ### Fixed diff --git a/packages/auth/NEXT-JS.md b/packages/auth/NEXT-JS.md index 75541e6d..cc118cbd 100644 --- a/packages/auth/NEXT-JS.md +++ b/packages/auth/NEXT-JS.md @@ -421,12 +421,13 @@ export async function signOut() { The `auth` provides access to all Neon Auth APIs: - **Session**: `getSession()`, `listSessions()`, `revokeSession()`, `revokeOtherSessions()` -- **Auth**: `signIn.email()`, `signIn.social()`, `signIn.emailOtp()`, `signUp.email()`, `signOut()` +- **Auth**: `signIn.email()`, `signIn.social()`, `signIn.emailOtp()`, `signIn.phoneNumber()`, `signUp.email()`, `signOut()` - **User**: `updateUser()`, `changePassword()`, `deleteUser()`, `sendVerificationEmail()` - **Organization**: `organization.create()`, `organization.list()`, `organization.inviteMember()`, `organization.removeMember()`, etc. - **Admin**: `admin.listUsers()`, `admin.banUser()`, `admin.setRole()`, `admin.createUser()`, etc. - **Email OTP**: `emailOtp.sendVerificationOtp()`, `emailOtp.verifyEmail()` - **OAuth provider**: `oauth2.publicClient()`, `oauth2.consent()`, `oauth2.getConsents()`, etc. +- **Phone Number**: `phoneNumber.sendOtp()`, `phoneNumber.verify()`, `phoneNumber.requestPasswordReset()`, `phoneNumber.resetPassword()` ## OAuth provider (MCP) diff --git a/packages/auth/src/server/client-factory.test.ts b/packages/auth/src/server/client-factory.test.ts index d2ce7d67..a4e36b2c 100644 --- a/packages/auth/src/server/client-factory.test.ts +++ b/packages/auth/src/server/client-factory.test.ts @@ -10,6 +10,10 @@ import { import { createAuthServer } from './client-factory'; import type { RequestContext } from './request-context'; import type { NeonAuthServer } from './types'; +import { + NEON_AUTH_SESSION_COOKIE_NAME, + NEON_AUTH_SESSION_DATA_COOKIE_NAME, +} from './constants'; const TEST_SECRET = 'test-secret-at-least-32-characters-long!'; const TEST_BASE_URL = 'https://auth.example.com'; @@ -652,3 +656,164 @@ describe('createAuthServer endpoint paths', () => { expect(JSON.parse(init.body as string)).toEqual({ slug: 'acme' }); }); }); + +describe('createAuthServer phone number', () => { + const originalFetch = globalThis.fetch; + let fetchMock: ReturnType; + let setCookieSpy: ReturnType>; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + setCookieSpy = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function makeServer() { + return createAuthServer({ + baseUrl: TEST_BASE_URL, + context: () => ({ + getCookies: () => '', + setCookie: setCookieSpy, + getHeader: () => null, + getOrigin: () => 'https://app.example.com', + getFramework: () => 'test', + }), + cookieSecret: TEST_SECRET, + }); + } + + function upstreamWithSessionCookie(body: unknown): Response { + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.append( + 'Set-Cookie', + `${NEON_AUTH_SESSION_COOKIE_NAME}=tok; Path=/; HttpOnly; Max-Age=3600` + ); + return Response.json(body, { status: 200, headers }); + } + + function sessionResponse(): Response { + const now = new Date().toISOString(); + return Response.json( + { + session: { + id: 's1', + userId: 'u1', + token: 'tok', + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + createdAt: now, + updatedAt: now, + }, + user: { id: 'u1', email: 'a@b.com', createdAt: now, updatedAt: now }, + }, + { status: 200 } + ); + } + + function lastCall(): { path: string; init: RequestInit } { + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + return { path: new URL(url).pathname, init }; + } + + const cases = [ + { + name: 'phoneNumber.sendOtp', + suffix: '/phone-number/send-otp', + body: { phoneNumber: '+15555550100' }, + call: (s: ReturnType) => + s.phoneNumber.sendOtp({ phoneNumber: '+15555550100' }), + }, + { + name: 'phoneNumber.requestPasswordReset', + suffix: '/phone-number/request-password-reset', + body: { phoneNumber: '+15555550100' }, + call: (s: ReturnType) => + s.phoneNumber.requestPasswordReset({ phoneNumber: '+15555550100' }), + }, + { + name: 'phoneNumber.resetPassword', + suffix: '/phone-number/reset-password', + body: { phoneNumber: '+15555550100', otp: '123456', newPassword: 'pw-12345678' }, + call: (s: ReturnType) => + s.phoneNumber.resetPassword({ + phoneNumber: '+15555550100', + otp: '123456', + newPassword: 'pw-12345678', + }), + }, + { + name: 'signIn.phoneNumber', + suffix: '/sign-in/phone-number', + body: { phoneNumber: '+15555550100', password: 'pw-12345678' }, + call: (s: ReturnType) => + s.signIn.phoneNumber({ phoneNumber: '+15555550100', password: 'pw-12345678' }), + }, + { + name: 'phoneNumber.verify', + suffix: '/phone-number/verify', + body: { phoneNumber: '+15555550100', code: '123456' }, + call: (s: ReturnType) => + s.phoneNumber.verify({ phoneNumber: '+15555550100', code: '123456' }), + }, + ]; + + test.each(cases)('$name POSTs JSON to $suffix', async ({ suffix, body, call }) => { + fetchMock.mockResolvedValueOnce(Response.json({ status: true }, { status: 200 })); + + await call(makeServer()); + + const { path, init } = lastCall(); + expect(path.endsWith(suffix)).toBe(true); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual(body); + }); + + test('signIn.phoneNumber sets the session and session_data cookies', async () => { + fetchMock + .mockResolvedValueOnce(upstreamWithSessionCookie({ token: 'tok', user: { id: 'u1' } })) + .mockResolvedValueOnce(sessionResponse()); + + await makeServer().signIn.phoneNumber({ + phoneNumber: '+15555550100', + password: 'pw-12345678', + }); + + const names = setCookieSpy.mock.calls.map(c => c[0]); + expect(names).toContain(NEON_AUTH_SESSION_COOKIE_NAME); + expect(names).toContain(NEON_AUTH_SESSION_DATA_COOKIE_NAME); + }); + + test('phoneNumber.verify sets the session and session_data cookies', async () => { + fetchMock + .mockResolvedValueOnce( + upstreamWithSessionCookie({ status: true, token: 'tok', user: { id: 'u1' } }) + ) + .mockResolvedValueOnce(sessionResponse()); + + await makeServer().phoneNumber.verify({ + phoneNumber: '+15555550100', + code: '123456', + }); + + const names = setCookieSpy.mock.calls.map(c => c[0]); + expect(names).toContain(NEON_AUTH_SESSION_COOKIE_NAME); + expect(names).toContain(NEON_AUTH_SESSION_DATA_COOKIE_NAME); + }); + + test('phoneNumber.verify mints no session_data when upstream sets no cookie (disableSession)', async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ status: true, token: null, user: { id: 'u1' } }, { status: 200 }) + ); + + await makeServer().phoneNumber.verify({ + phoneNumber: '+15555550100', + code: '123456', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(setCookieSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/auth/src/server/endpoints.ts b/packages/auth/src/server/endpoints.ts index 0a513747..966582a1 100644 --- a/packages/auth/src/server/endpoints.ts +++ b/packages/auth/src/server/endpoints.ts @@ -63,6 +63,7 @@ export const API_ENDPOINTS = { social: { path: 'sign-in/social', method: 'POST' }, emailOtp: { path: 'sign-in/email-otp', method: 'POST' }, magicLink: { path: 'sign-in/magic-link', method: 'POST' }, + phoneNumber: { path: 'sign-in/phone-number', method: 'POST' }, }, signUp: { email: { path: 'sign-up/email', method: 'POST' }, @@ -149,6 +150,14 @@ export const API_ENDPOINTS = { resetPassword: { path: 'email-otp/reset-password', method: 'POST' }, }, + // Phone Number + phoneNumber: { + sendOtp: { path: 'phone-number/send-otp', method: 'POST' }, + verify: { path: 'phone-number/verify', method: 'POST' }, + requestPasswordReset: { path: 'phone-number/request-password-reset', method: 'POST' }, + resetPassword: { path: 'phone-number/reset-password', method: 'POST' }, + }, + // Magic Link magicLink: { verify: { path: 'magic-link/verify', method: 'GET' }, From 1a18d2d891baa289fdfc476afa8dfeb3a232e945 Mon Sep 17 00:00:00 2001 From: Shridhar Deshmukh Date: Wed, 21 Oct 2026 11:33:08 +0000 Subject: [PATCH 2/5] test(auth): tighten phone number server tests - rename lastCall to firstCall and assert a single fetch before inspecting it - compare the exact request pathname instead of endsWith - send disableSession in the verify test and assert it is forwarded Co-authored-by: Isaac --- packages/auth/src/server/client-factory.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/auth/src/server/client-factory.test.ts b/packages/auth/src/server/client-factory.test.ts index a4e36b2c..e1d1f4ec 100644 --- a/packages/auth/src/server/client-factory.test.ts +++ b/packages/auth/src/server/client-factory.test.ts @@ -713,7 +713,7 @@ describe('createAuthServer phone number', () => { ); } - function lastCall(): { path: string; init: RequestInit } { + function firstCall(): { path: string; init: RequestInit } { const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; return { path: new URL(url).pathname, init }; } @@ -765,8 +765,9 @@ describe('createAuthServer phone number', () => { await call(makeServer()); - const { path, init } = lastCall(); - expect(path.endsWith(suffix)).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const { path, init } = firstCall(); + expect(path).toBe(suffix); expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual(body); }); @@ -803,7 +804,7 @@ describe('createAuthServer phone number', () => { expect(names).toContain(NEON_AUTH_SESSION_DATA_COOKIE_NAME); }); - test('phoneNumber.verify mints no session_data when upstream sets no cookie (disableSession)', async () => { + test('phoneNumber.verify forwards disableSession and mints no session_data when upstream sets no cookie', async () => { fetchMock.mockResolvedValueOnce( Response.json({ status: true, token: null, user: { id: 'u1' } }, { status: 200 }) ); @@ -811,9 +812,15 @@ describe('createAuthServer phone number', () => { await makeServer().phoneNumber.verify({ phoneNumber: '+15555550100', code: '123456', + disableSession: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(firstCall().init.body as string)).toEqual({ + phoneNumber: '+15555550100', + code: '123456', + disableSession: true, + }); expect(setCookieSpy).not.toHaveBeenCalled(); }); }); From 795a4e7b36840d15219a7e55fda72a2fa8f90b56 Mon Sep 17 00:00:00 2001 From: Shridhar Deshmukh Date: Fri, 23 Oct 2026 21:22:15 +0000 Subject: [PATCH 3/5] fix(auth): address review feedback on phone number methods - Pin phone number request bodies to Better Auth's own zod schemas - Add phoneNumber to the mocked session user - Cover signIn.phoneNumber with no session cookie on the two-factor path Co-authored-by: Isaac --- .../auth/src/server/client-factory.test.ts | 23 +++++++++++++++- packages/auth/src/server/endpoints.test.ts | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/server/client-factory.test.ts b/packages/auth/src/server/client-factory.test.ts index e1d1f4ec..f3251819 100644 --- a/packages/auth/src/server/client-factory.test.ts +++ b/packages/auth/src/server/client-factory.test.ts @@ -707,7 +707,14 @@ describe('createAuthServer phone number', () => { createdAt: now, updatedAt: now, }, - user: { id: 'u1', email: 'a@b.com', createdAt: now, updatedAt: now }, + user: { + id: 'u1', + email: 'a@b.com', + phoneNumber: '+15555550100', + phoneNumberVerified: true, + createdAt: now, + updatedAt: now, + }, }, { status: 200 } ); @@ -787,6 +794,20 @@ describe('createAuthServer phone number', () => { expect(names).toContain(NEON_AUTH_SESSION_DATA_COOKIE_NAME); }); + test('signIn.phoneNumber sets no cookies when upstream requires two-factor', async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ twoFactorRedirect: true }, { status: 200 }) + ); + + await makeServer().signIn.phoneNumber({ + phoneNumber: '+15555550100', + password: 'pw-12345678', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(setCookieSpy).not.toHaveBeenCalled(); + }); + test('phoneNumber.verify sets the session and session_data cookies', async () => { fetchMock .mockResolvedValueOnce( diff --git a/packages/auth/src/server/endpoints.test.ts b/packages/auth/src/server/endpoints.test.ts index caab78b3..8da47264 100644 --- a/packages/auth/src/server/endpoints.test.ts +++ b/packages/auth/src/server/endpoints.test.ts @@ -127,3 +127,30 @@ describe('API_ENDPOINTS matches Better Auth routes', () => { ).toContain(method); }); }); + +// Server methods forward the caller's body as-is, so a wrong field name only +// surfaces as an upstream 400. Parse the bodies the SDK tests send against the +// plugin's own schemas. +describe('phone number request bodies match Better Auth schemas', () => { + type Parseable = { options: { body: { safeParse: (v: unknown) => { success: boolean } } } }; + const api = auth.api as unknown as Record; + const phone = '+15555550100'; + + test.each([ + ['signInPhoneNumber', { phoneNumber: phone, password: 'pw-12345678' }], + ['sendPhoneNumberOTP', { phoneNumber: phone }], + ['verifyPhoneNumber', { phoneNumber: phone, code: '123456' }], + ['verifyPhoneNumber', { phoneNumber: phone, code: '123456', disableSession: true }], + ['requestPasswordResetPhoneNumber', { phoneNumber: phone }], + ['resetPasswordPhoneNumber', { phoneNumber: phone, otp: '123456', newPassword: 'pw-12345678' }], + ])('%s accepts %j', (endpoint, body) => { + expect(api[endpoint].options.body.safeParse(body).success).toBe(true); + }); + + test.each([ + ['verifyPhoneNumber', { phoneNumber: phone, otp: '123456' }], + ['resetPasswordPhoneNumber', { phoneNumber: phone, code: '123456', password: 'pw-12345678' }], + ])('%s rejects %j', (endpoint, body) => { + expect(api[endpoint].options.body.safeParse(body).success).toBe(false); + }); +}); From d65e372c2a0f67d4e828d965eb1cd4d171b986b8 Mon Sep 17 00:00:00 2001 From: Shridhar Deshmukh Date: Fri, 23 Oct 2026 21:38:08 +0000 Subject: [PATCH 4/5] test(auth): describe what the phone body schema check pins Co-authored-by: Isaac --- packages/auth/src/server/endpoints.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/server/endpoints.test.ts b/packages/auth/src/server/endpoints.test.ts index 8da47264..4a4b226e 100644 --- a/packages/auth/src/server/endpoints.test.ts +++ b/packages/auth/src/server/endpoints.test.ts @@ -129,8 +129,8 @@ describe('API_ENDPOINTS matches Better Auth routes', () => { }); // Server methods forward the caller's body as-is, so a wrong field name only -// surfaces as an upstream 400. Parse the bodies the SDK tests send against the -// plugin's own schemas. +// surfaces as an upstream 400. Pin the documented field names to the plugin's +// own schemas so an upstream rename fails here. describe('phone number request bodies match Better Auth schemas', () => { type Parseable = { options: { body: { safeParse: (v: unknown) => { success: boolean } } } }; const api = auth.api as unknown as Record; From 1b4141397c1c81db47b0276007b1ad688b15c89b Mon Sep 17 00:00:00 2001 From: Shridhar Deshmukh Date: Fri, 23 Oct 2026 22:04:15 +0000 Subject: [PATCH 5/5] test(auth): assert phone body fields survive schema parsing Co-authored-by: Isaac --- packages/auth/src/server/endpoints.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/server/endpoints.test.ts b/packages/auth/src/server/endpoints.test.ts index 4a4b226e..8108c14c 100644 --- a/packages/auth/src/server/endpoints.test.ts +++ b/packages/auth/src/server/endpoints.test.ts @@ -132,7 +132,9 @@ describe('API_ENDPOINTS matches Better Auth routes', () => { // surfaces as an upstream 400. Pin the documented field names to the plugin's // own schemas so an upstream rename fails here. describe('phone number request bodies match Better Auth schemas', () => { - type Parseable = { options: { body: { safeParse: (v: unknown) => { success: boolean } } } }; + type Parseable = { + options: { body: { safeParse: (v: unknown) => { success: boolean; data?: unknown } } }; + }; const api = auth.api as unknown as Record; const phone = '+15555550100'; @@ -144,13 +146,18 @@ describe('phone number request bodies match Better Auth schemas', () => { ['requestPasswordResetPhoneNumber', { phoneNumber: phone }], ['resetPasswordPhoneNumber', { phoneNumber: phone, otp: '123456', newPassword: 'pw-12345678' }], ])('%s accepts %j', (endpoint, body) => { - expect(api[endpoint].options.body.safeParse(body).success).toBe(true); + expect(api[endpoint], `no Better Auth endpoint ${endpoint}`).toBeDefined(); + const result = api[endpoint].options.body.safeParse(body); + expect(result.success).toBe(true); + // Zod strips unknown keys, so success alone would not catch a dropped field. + expect(result.data).toMatchObject(body); }); test.each([ ['verifyPhoneNumber', { phoneNumber: phone, otp: '123456' }], ['resetPasswordPhoneNumber', { phoneNumber: phone, code: '123456', password: 'pw-12345678' }], ])('%s rejects %j', (endpoint, body) => { + expect(api[endpoint], `no Better Auth endpoint ${endpoint}`).toBeDefined(); expect(api[endpoint].options.body.safeParse(body).success).toBe(false); }); });