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
3 changes: 0 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,6 @@ cache:collection-content:blog-posts:limit:50
# Generate a secure random string
openssl rand -base64 32 | wrangler secret put JWT_SECRET --env production

# Admin password for initial setup (optional)
echo "your-secure-admin-password" | wrangler secret put ADMIN_PASSWORD --env production

# R2 Access credentials (if using external access)
echo "your-r2-access-key-id" | wrangler secret put R2_ACCESS_KEY_ID --env production
echo "your-r2-secret-key" | wrangler secret put R2_SECRET_ACCESS_KEY --env production
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* `POST /auth/register/form` — the endpoint the register page actually posts to (htmx).
*
* Driven against a REAL better-sqlite3 D1, then signed in through a REAL Better Auth instance.
*
* Why this file exists: the form handler inserted `auth_user` but never the `auth_account`
* credential row that Better Auth's sign-in reads the password from. Registration returned 200,
* the first user was even made admin, and every sign-in then failed with "Invalid email or
* password" (BA logs "Credential account not found"). The JSON `/auth/register` route already
* wrote both rows; only the path the UI uses was broken. Reported in #1054.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { Hono } from 'hono'
import authRoutes from '../../routes/auth'
import { createAuth } from '../../auth/config'
import { RbacService } from '../../services/rbac'
import { bootstrapDocumentTypes } from '../../services/document-types-seed'
import { createTestD1, type TestD1 } from '../utils/d1-sqlite'
import type { Bindings } from '../../app'

const ORIGIN = 'https://sonic.test'
const JWT_SECRET = 'test-jwt-secret-value-32-chars-long!!'
const PASSWORD = 'FormTest12345'

let db: TestD1
let kv: Map<string, string>

function env() {
return {
DB: db,
JWT_SECRET,
BETTER_AUTH_SECRET: 'test-better-auth-secret-at-least-32-chars',
BETTER_AUTH_URL: ORIGIN,
} as unknown as Bindings
}

/** The SonicJS auth routes. No CACHE_KV, so `rateLimit` passes through. */
function makeApp() {
const app = new Hono()
app.use('*', async (c, next) => {
c.env = env() as never
await next()
})
app.route('/auth', authRoutes)
return app
}

function registerForm(email: string, password = PASSWORD) {
const body = new URLSearchParams({ email, password, firstName: 'Form', lastName: 'User' })
return makeApp().request('/auth/register/form', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
}

/** Sign in through Better Auth itself — the path `/auth/login` delegates to. */
async function baSignIn(email: string, password = PASSWORD) {
const auth = createAuth(
{
...env(),
CACHE_KV: {
get: async (k: string) => kv.get(k) ?? null,
put: async (k: string, v: string) => void kv.set(k, v),
delete: async (k: string) => void kv.delete(k),
},
} as unknown as Bindings,
undefined,
ORIGIN,
)
return auth.handler(
new Request(`${ORIGIN}/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
body: JSON.stringify({ email, password }),
}),
)
}

function credentialRows(email: string) {
return db.raw
.prepare(
`SELECT a.id, a.password FROM auth_account a JOIN auth_user u ON u.id = a.user_id
WHERE u.email = ? AND a.provider_id = 'credential'`,
)
.all(email) as Array<{ id: string; password: string }>
}

function rbacRoleNames(email: string) {
const user = db.raw.prepare(`SELECT id FROM auth_user WHERE email = ?`).get(email) as { id: string } | undefined
if (!user) return []
return new RbacService(db as never).getRolesForUser(user.id).then((roles) => roles.map((r) => r.name))
}

beforeEach(() => {
db = createTestD1()
kv = new Map()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})

afterEach(() => {
db.close()
vi.restoreAllMocks()
})

describe('POST /auth/register/form', () => {
// The handler assigns an RBAC role; give it the seeded roles a booted app has.
beforeEach(async () => {
await bootstrapDocumentTypes(db as never)
await new RbacService(db as never).ensureSystemRbacSeed()
})

it('creates the credential row, so the first (admin) user can sign in', async () => {
const res = await registerForm('first@form.test')
expect(res.status).toBe(200)

const user = db.raw
.prepare(`SELECT id, role, password_hash FROM auth_user WHERE email = ?`)
.get('first@form.test') as { id: string; role: string; password_hash: string }
expect(user.role).toBe('admin')

const creds = credentialRows('first@form.test')
expect(creds).toHaveLength(1)
expect(creds[0]!.password).toBe(user.password_hash)

const signIn = await baSignIn('first@form.test')
expect(signIn.status, `sign-in failed: ${await signIn.clone().text()}`).toBe(200)
})

it('does the same for a later (viewer) user', async () => {
await registerForm('first@form.test')
const res = await registerForm('second@form.test')
expect(res.status).toBe(200)

expect(credentialRows('second@form.test')).toHaveLength(1)
expect((await baSignIn('second@form.test')).status).toBe(200)
})

it('still rejects a wrong password for a form-registered user', async () => {
await registerForm('first@form.test')
expect((await baSignIn('first@form.test', 'not-the-password')).status).toBe(401)
})

it('writes no rows when the email is already taken', async () => {
await registerForm('first@form.test')
await registerForm('first@form.test', 'AnotherPass12345')

expect(
(db.raw.prepare(`SELECT COUNT(*) AS n FROM auth_user WHERE email = ?`).get('first@form.test') as { n: number }).n,
).toBe(1)
expect(credentialRows('first@form.test')).toHaveLength(1)
expect((await baSignIn('first@form.test')).status).toBe(200)
})
})

describe('POST /auth/register/form — first user and RBAC', () => {
it('makes the first user an RBAC admin even when the first boot left RBAC unseeded', async () => {
// No bootstrapDocumentTypes / ensureSystemRbacSeed here: the state a failed first boot leaves (#1017).
const res = await registerForm('first@form.test')
expect(res.status).toBe(200)
expect(await res.text()).toContain('Account created successfully')

expect(await rbacRoleNames('first@form.test')).toEqual(['admin'])
expect((await baSignIn('first@form.test')).status).toBe(200)
})

it('removes the account again when the admin role cannot be assigned, so a retry works', async () => {
const spy = vi
.spyOn(RbacService.prototype, 'addUserRoleByName')
.mockRejectedValueOnce(new Error('D1 unavailable'))

const failed = await registerForm('first@form.test')
expect(await failed.text()).toContain('Registration failed')
expect(db.raw.prepare(`SELECT COUNT(*) AS n FROM auth_user`).get()).toEqual({ n: 0 })
expect(db.raw.prepare(`SELECT COUNT(*) AS n FROM auth_account`).get()).toEqual({ n: 0 })

spy.mockRestore()
const retry = await registerForm('first@form.test')
expect(await retry.text()).toContain('Account created successfully')
expect(await rbacRoleNames('first@form.test')).toEqual(['admin'])
})
})
92 changes: 65 additions & 27 deletions packages/core/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,47 +582,85 @@ authRoutes.post('/register/form',

// Determine role: first user gets admin, others get viewer
const role = isFirstUser ? 'admin' : 'viewer'
const rbacService = new RbacService(db)

// Admin access is RBAC-backed, so the first user needs the system roles to exist. A failed
// first boot can leave them unseeded (#1017). Seed them before creating the account; types
// first, because D1 enforces the documents → document_types FK (same as /auth/seed-admin).
// This runs once per site, so the cost doesn't matter.
if (isFirstUser) {
await bootstrapDocumentTypes(db)
await rbacService.ensureSystemRbacSeed()
}

// Create user
const userId = crypto.randomUUID()
const now = new Date()
const nowSec = Math.floor(now.getTime() / 1000)

await db.batch([
db.prepare(`
INSERT INTO auth_user (id, email, first_name, last_name, password_hash, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
userId,
normalizedEmail,
firstName,
lastName,
passwordHash,
role,
1, // is_active
now.getTime(),
now.getTime()
),
// Better Auth sign-in/email requires an auth_account credential row — create it alongside auth_user
db.prepare(`INSERT OR IGNORE INTO auth_account (id, user_id, account_id, provider_id, password, created_at, updated_at)
VALUES (?, ?, ?, 'credential', ?, ?, ?)`)
.bind(`cred-${userId}`, userId, userId, passwordHash, nowSec, nowSec),
])

// Assign the RBAC role before anything optional. For the first user this is what makes them
// the admin. If it can't be assigned, remove the account again: an "admin" without the role
// gets 403 everywhere, and with no admin on record the next sign-up would claim the slot.
try {
await rbacService.addUserRoleByName(userId, role)
if (isFirstUser) {
const assigned = await rbacService.getRolesForUser(userId)
if (!assigned.some((r) => r.name === 'admin')) {
throw new Error('Admin role could not be assigned to the first user')
}
}
} catch (error) {
await db.batch([
db.prepare('DELETE FROM auth_account WHERE user_id = ?').bind(userId),
db.prepare('DELETE FROM auth_user WHERE id = ?').bind(userId),
])
throw error
}

await db.prepare(`
INSERT INTO auth_user (id, email, first_name, last_name, password_hash, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
userId,
normalizedEmail,
firstName,
lastName,
passwordHash,
role,
1, // is_active
now.getTime(),
now.getTime()
).run()

// Save custom profile fields if configured
// Save custom profile fields if configured. The account is complete and can sign in by now,
// so a failure here is logged instead of reported as "Registration failed": a retry would
// only get "User with this email already exists". The fields can be filled in later.
const profileConfig = getUserProfileConfig()
if (profileConfig) {
const regFields = getRegistrationFields()
if (regFields.length > 0) {
const customData: Record<string, any> = { ...getProfileFieldDefaults() }
for (const field of regFields) {
const raw = formData.get(field.name)?.toString()
if (raw !== undefined && raw !== null) {
customData[field.name] = raw
try {
const customData: Record<string, any> = { ...getProfileFieldDefaults() }
for (const field of regFields) {
const raw = formData.get(field.name)?.toString()
if (raw !== undefined && raw !== null) {
customData[field.name] = raw
}
}
const sanitized = sanitizeCustomData(customData, profileConfig)
await saveCustomData(db, userId, sanitized)
} catch (error) {
console.error('Registration: saving custom profile fields failed:', error)
}
const sanitized = sanitizeCustomData(customData, profileConfig)
await saveCustomData(db, userId, sanitized)
}
}

// Assign RBAC role so the new user passes requireRbac('portal', 'access')
const rbacService = new RbacService(db)
await rbacService.addUserRoleByName(userId, role)

// Fire auth:registration:completed (fire-and-forget — does not block the response)
dispatchHookEvent(
c,
Expand Down
68 changes: 68 additions & 0 deletions tests/e2e/113-register-form-login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { test, expect, type Page } from '@playwright/test'

/**
* An account created through the register PAGE can sign in.
*
* The page posts to `/auth/register/form` (htmx), not to the JSON `/auth/register`. That handler
* used to create `auth_user` without the `auth_account` credential row Better Auth reads the
* password from, so registration succeeded and every sign-in failed with "Invalid email or
* password" (#1054). This drives the real page, then signs in.
*/

/**
* Register through the page, waiting out registration being switched off.
*
* Spec 37 turns registration off for a few seconds at a time on the same preview database
* (`fullyParallel`, 4 CI workers), and always switches it back on in a `finally`. Toggling the
* setting from here would break 37's own assertions, so this waits for it to come back instead.
*/
async function registerThroughPage(page: Page, email: string, password: string) {
for (let attempt = 0; attempt < 10; attempt++) {
await page.goto('/auth/register')
// Registration off: the page redirects to /auth/login.
if (!new URL(page.url()).pathname.startsWith('/auth/register')) {
await page.waitForTimeout(2000)
continue
}

await page.fill('input[name="firstName"]', 'Form')
await page.fill('input[name="lastName"]', 'Register')
await page.fill('input[name="email"]', email)
await page.fill('input[name="password"]', password)

const response = page.waitForResponse(
(r) => r.url().includes('/auth/register/form') && r.request().method() === 'POST',
)
await page.click('button[type="submit"]')
const res = await response
const body = await res.text()

// Switched off between loading the page and submitting it.
if (body.includes('Registration is currently disabled')) {
await page.waitForTimeout(2000)
continue
}

expect(res.status()).toBe(200)
expect(body).toContain('Account created successfully')
return
}
throw new Error('Registration stayed disabled for 20s; spec 37 normally re-enables it within seconds')
}

test.describe('Register page → sign in @auth', () => {
test('a user registered through the form can log in', async ({ page, request }) => {
const email = `form.register.${Date.now()}@example.com`
const password = 'FormRegister12345!'

await registerThroughPage(page, email, password)

const login = await request.post('/auth/login', { data: { email, password } })
expect(login.status(), await login.text()).toBe(200)
const body = await login.json()
expect(body.user?.email).toBe(email)

const wrong = await request.post('/auth/login', { data: { email, password: 'not-the-password' } })
expect(wrong.status()).toBe(401)
})
})
Loading
Loading