feat(auth): add better-auth username plugin (#279)

* feat(auth): add better-auth username plugin

Enable username-based registration and sign-in by adding the username
plugin to both server and client auth configurations. Adds username
and display_username columns to the user table via migration.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(auth): add username plugin tests and fix test setup

Add schema and integration tests for the username plugin. Fix the
in-memory SQLite test setup to include username columns so existing
auth tests don't break.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jasper Van
2026-04-12 21:20:13 -04:00
committed by GitHub
parent 3f15a6af52
commit da08b014ae
8 changed files with 160 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
-- Add username columns for better-auth username plugin
ALTER TABLE `user` ADD `username` text;--> statement-breakpoint
ALTER TABLE `user` ADD `display_username` text;--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);
+7
View File
@@ -29,6 +29,13 @@
"when": 1775090000000,
"tag": "0003_recycle_bin",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1775100000000,
"tag": "0004_username_plugin",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
import crypto from 'node:crypto'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin, organization } from 'better-auth/plugins'
import { admin, organization, username } from 'better-auth/plugins'
import { count, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import * as authSchema from './db/auth-schema'
@@ -49,7 +49,7 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust
maxAge: 60 * 5,
},
},
plugins: [admin(), organization()],
plugins: [admin(), organization(), username()],
databaseHooks: {
user: {
create: {
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { user } from './auth-schema.js'
describe('auth-schema user table', () => {
it('has a username column', () => {
expect(user.username).toBeDefined()
})
it('username column maps to the "username" SQL column name', () => {
expect(user.username.name).toBe('username')
})
it('username column is text type', () => {
expect(user.username.columnType).toBe('SQLiteText')
})
it('username column has a unique constraint', () => {
expect(user.username.isUnique).toBe(true)
})
it('username column is nullable (no notNull)', () => {
expect(user.username.notNull).toBe(false)
})
it('has a displayUsername column', () => {
expect(user.displayUsername).toBeDefined()
})
it('displayUsername column maps to the "display_username" SQL column name', () => {
expect(user.displayUsername.name).toBe('display_username')
})
it('displayUsername column is text type', () => {
expect(user.displayUsername.columnType).toBe('SQLiteText')
})
it('displayUsername column has no unique constraint', () => {
expect(user.displayUsername.isUnique).toBeFalsy()
})
it('displayUsername column is nullable (no notNull)', () => {
expect(user.displayUsername.notNull).toBe(false)
})
})
+2
View File
@@ -11,6 +11,8 @@ export const user = sqliteTable('user', {
banned: integer('banned', { mode: 'boolean' }).default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
username: text('username').unique(),
displayUsername: text('display_username'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
+97
View File
@@ -0,0 +1,97 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
describe('migration 0004_username_plugin.sql', () => {
const migrationPath = join(process.cwd(), 'migrations/0004_username_plugin.sql')
const sql = readFileSync(migrationPath, 'utf-8')
it('adds the username column to the user table', () => {
expect(sql).toMatch(/ALTER TABLE.*`user`.*ADD.*`username`.*text/i)
})
it('adds the display_username column to the user table', () => {
expect(sql).toMatch(/ALTER TABLE.*`user`.*ADD.*`display_username`.*text/i)
})
it('creates a unique index on the username column', () => {
expect(sql).toMatch(/CREATE UNIQUE INDEX.*`user_username_unique`.*ON.*`user`.*\(`username`\)/i)
})
})
describe('username plugin — sign-up with username', () => {
it('sign-up with username stores the username on the user record', async () => {
const { app, db } = createTestApp()
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Alice',
email: 'alice@example.com',
password: 'password123456',
username: 'alice42',
}),
})
const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'alice@example.com'))
expect(users[0].username).toBe('alice42')
})
it('sign-up without username leaves the username column null', async () => {
const { app, db } = createTestApp()
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Bob', email: 'bob@example.com', password: 'password123456' }),
})
const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'bob@example.com'))
expect(users[0].username).toBeNull()
})
it('sign-up with duplicate username returns a non-200 response', async () => {
const { app } = createTestApp()
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'First',
email: 'first@example.com',
password: 'password123456',
username: 'shared_handle',
}),
})
const res = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Second',
email: 'second@example.com',
password: 'password123456',
username: 'shared_handle',
}),
})
expect(res.status).not.toBe(200)
})
it('two users with different usernames both register successfully', async () => {
const { app, db } = createTestApp()
const res1 = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'User1', email: 'u1@example.com', password: 'password123456', username: 'user1' }),
})
const res2 = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'User2', email: 'u2@example.com', password: 'password123456', username: 'user2' }),
})
expect(res1.status).toBe(200)
expect(res2.status).toBe(200)
const allUsers = await db.select().from(authSchema.user)
const usernames = allUsers.map((u) => u.username)
expect(usernames).toContain('user1')
expect(usernames).toContain('user2')
})
})
+2
View File
@@ -17,6 +17,8 @@ const AUTH_SCHEMA_SQL = `
banned INTEGER DEFAULT 0,
ban_reason TEXT,
ban_expires INTEGER,
username TEXT UNIQUE,
display_username TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
+2
View File
@@ -1,7 +1,9 @@
import { usernameClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL || '',
plugins: [usernameClient()],
})
export const { signIn, signUp, signOut, useSession } = authClient