Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/auth/NEXT-JS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
193 changes: 193 additions & 0 deletions packages/auth/src/server/client-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -652,3 +656,192 @@ 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<typeof vi.fn>;
let setCookieSpy: ReturnType<typeof vi.fn<RequestContext['setCookie']>>;

beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
setCookieSpy = vi.fn<RequestContext['setCookie']>();
});

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',
phoneNumber: '+15555550100',
phoneNumberVerified: true,
createdAt: now,
updatedAt: now,
},
},
{ status: 200 }
);
}

function firstCall(): { 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<typeof makeServer>) =>
s.phoneNumber.sendOtp({ phoneNumber: '+15555550100' }),
},
{
name: 'phoneNumber.requestPasswordReset',
suffix: '/phone-number/request-password-reset',
body: { phoneNumber: '+15555550100' },
call: (s: ReturnType<typeof makeServer>) =>
s.phoneNumber.requestPasswordReset({ phoneNumber: '+15555550100' }),
Comment thread
Shridhad marked this conversation as resolved.
},
{
name: 'phoneNumber.resetPassword',
suffix: '/phone-number/reset-password',
body: { phoneNumber: '+15555550100', otp: '123456', newPassword: 'pw-12345678' },
Comment thread
Shridhad marked this conversation as resolved.
call: (s: ReturnType<typeof makeServer>) =>
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<typeof makeServer>) =>
s.signIn.phoneNumber({ phoneNumber: '+15555550100', password: 'pw-12345678' }),
},
{
name: 'phoneNumber.verify',
suffix: '/phone-number/verify',
body: { phoneNumber: '+15555550100', code: '123456' },
Comment thread
Shridhad marked this conversation as resolved.
call: (s: ReturnType<typeof makeServer>) =>
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());

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);
});

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('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
Comment thread
Shridhad marked this conversation as resolved.
.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 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 })
);

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();
});
});
34 changes: 34 additions & 0 deletions packages/auth/src/server/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,37 @@ 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. 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', () => {
Comment thread
Shridhad marked this conversation as resolved.
type Parseable = {
options: { body: { safeParse: (v: unknown) => { success: boolean; data?: unknown } } };
};
const api = auth.api as unknown as Record<string, Parseable>;
const phone = '+15555550100';

test.each([
Comment thread
Shridhad marked this conversation as resolved.
['signInPhoneNumber', { phoneNumber: phone, password: 'pw-12345678' }],
['sendPhoneNumberOTP', { phoneNumber: phone }],
['verifyPhoneNumber', { phoneNumber: phone, code: '123456' }],
['verifyPhoneNumber', { phoneNumber: phone, code: '123456', disableSession: true }],
Comment thread
Shridhad marked this conversation as resolved.
['requestPasswordResetPhoneNumber', { phoneNumber: phone }],
['resetPasswordPhoneNumber', { phoneNumber: phone, otp: '123456', newPassword: 'pw-12345678' }],
])('%s accepts %j', (endpoint, body) => {
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);
});
});
9 changes: 9 additions & 0 deletions packages/auth/src/server/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -149,6 +150,14 @@ export const API_ENDPOINTS = {
resetPassword: { path: 'email-otp/reset-password', method: 'POST' },
},

// Phone Number
Comment thread
Shridhad marked this conversation as resolved.
phoneNumber: {
sendOtp: { path: 'phone-number/send-otp', method: 'POST' },

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🖍️ redline (automated reviewer)

[question]
🤔 fetchWithAuth forwards only Cookie, Origin and the proxy header, no X-Forwarded-For or User-Agent. so every sendOtp from a server action hits neon auth from the app's egress IP. does phone-number/send-otp rate-limit by phone number or by IP? if by IP, all users share one SMS budget and abuse can't be traced to a client, and sms costs money per message.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. better-auth 1.7.6 rate-limits by client IP plus path (createRateLimitKey(ip, path)), and the phone-number plugin allows 10 requests per 60 seconds on /phone-number/*. Server methods send only Cookie, Origin and the proxy header, so calls from server actions all count against the app's egress IP. This applies to every createNeonAuth() server method (for example signIn.email), not just phone numbers. Fixing it means forwarding the client IP and the Neon Auth server trusting it, which is a separate change. Leaving this open for the maintainer to decide.

verify: { path: 'phone-number/verify', method: 'POST' },
requestPasswordReset: { path: 'phone-number/request-password-reset', method: 'POST' },
resetPassword: { path: 'phone-number/reset-password', method: 'POST' },
Comment thread
Shridhad marked this conversation as resolved.
Comment thread
Shridhad marked this conversation as resolved.
},

// Magic Link
magicLink: {
verify: { path: 'magic-link/verify', method: 'GET' },
Expand Down
Loading