feat(auth): add registration mode control and email verification (#283)

Enforce signup gating via auth_signup_mode system option (open/invite_only/closed).
Invite codes are atomically redeemed during signup. Email verification is
conditionally enabled when an email provider is configured.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

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-13 11:19:54 -04:00
committed by GitHub
parent 4a5fbb7f4a
commit 364cbc2aee
5 changed files with 491 additions and 14 deletions
+375
View File
@@ -0,0 +1,375 @@
import { describe, expect, it } from 'vitest'
import { createAuth } from './auth.js'
import * as authSchema from './db/auth-schema.js'
import * as schema from './db/schema.js'
import { generateInviteCodes } from './services/invite.js'
import { createTestApp } from './test/setup.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknown>) {
return ctx.app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test User', email, password: 'password123456', ...extra }),
})
}
describe('registration gate — first user always allowed', () => {
it('first user can register when auth_signup_mode is closed', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
const res = await signUp(ctx, 'first@example.com')
expect(res.status).toBe(200)
})
it('first user can register when auth_signup_mode is invite_only without a code', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
const res = await signUp(ctx, 'first@example.com')
expect(res.status).toBe(200)
})
it('first user is promoted to admin when auth_signup_mode is invite_only', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
const res = await signUp(ctx, 'first@example.com')
const body = (await res.json()) as { user: { role: string } }
expect(body.user.role).toBe('admin')
})
it('first user can register when auth_signup_mode is open', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' })
const res = await signUp(ctx, 'first@example.com')
expect(res.status).toBe(200)
})
})
describe('registration gate — open mode', () => {
it('second user can register when auth_signup_mode is not set (defaults to open)', async () => {
const ctx = await createTestApp()
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'second@example.com')
expect(res.status).toBe(200)
})
it('second user can register when auth_signup_mode is explicitly open', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'second@example.com')
expect(res.status).toBe(200)
})
})
describe('registration gate — closed mode', () => {
it('second user is rejected when auth_signup_mode is closed', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'blocked@example.com')
expect(res.status).not.toBe(200)
})
it('third user is also rejected when auth_signup_mode is closed', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
await signUp(ctx, 'first@example.com')
await signUp(ctx, 'second@example.com') // blocked
const res = await signUp(ctx, 'third@example.com')
expect(res.status).not.toBe(200)
})
it('closed mode returns 422 status code', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'blocked@example.com')
expect(res.status).toBe(422)
})
})
describe('registration gate — invite_only mode', () => {
it('second user is rejected when no invite code is provided', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'noinvite@example.com')
expect(res.status).not.toBe(200)
})
it('invite_only mode with no code returns 422 status code', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'noinvite@example.com')
expect(res.status).toBe(422)
})
it('second user is rejected when an invalid invite code is provided', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'badinvite@example.com', { inviteCode: 'BADCODE1' })
expect(res.status).not.toBe(200)
})
it('second user is rejected when invite code is expired', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const pastDate = new Date(Date.now() - 1000)
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1, pastDate)
// NOTE: inviteCode is not declared as an additionalField in the better-auth config,
// so it will not flow through to the databaseHooks before handler. This means
// the expired-code check is never reached — the hook sees no inviteCode and rejects
// with "An invite code is required to register" instead.
const res = await signUp(ctx, 'expired@example.com', { inviteCode: codeRow.code })
expect(res.status).not.toBe(200)
})
})
describe('getSignupMode — via auth_signup_mode system option', () => {
it('unknown value in auth_signup_mode falls back to open (second user succeeds)', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'unknown_value' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'second@example.com')
expect(res.status).toBe(200)
})
it('empty string in auth_signup_mode falls back to open', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: '' })
await signUp(ctx, 'first@example.com')
const res = await signUp(ctx, 'second@example.com')
expect(res.status).toBe(200)
})
})
describe('isEmailConfigured — via emailVerification conditional', () => {
it('createAuth succeeds when email_provider is not configured', async () => {
const ctx = await createTestApp()
expect(ctx.auth).toBeTruthy()
})
it('sign-up succeeds without email_provider configured', async () => {
const ctx = await createTestApp()
const res = await signUp(ctx, 'user@example.com')
expect(res.status).toBe(200)
})
it('send-verification-email is a no-op (returns early) when email_provider is not configured', async () => {
const ctx = await createTestApp()
// Sign up first so the user exists
await signUp(ctx, 'verify@example.com')
// Trigger the sendVerificationEmail callback — should not throw even without email config
const res = await ctx.app.request('/api/auth/send-verification-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'verify@example.com' }),
})
// The endpoint returns 200 regardless; the callback silently returns early
expect(res.status).toBe(200)
})
})
describe('buildVerificationEmailHtml — via send-verification-email with email_provider configured', () => {
it('send-verification-email triggers email send when email_provider is configured', async () => {
const { vi } = await import('vitest')
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values([
{ key: 'email_provider', value: 'http' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
{ key: 'email_http_api_key', value: 'my-api-key' },
])
await signUp(ctx, 'withmail@example.com')
const res = await ctx.app.request('/api/auth/send-verification-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'withmail@example.com' }),
})
expect(res.status).toBe(200)
// The email should have been sent via the HTTP provider
expect(fetchMock).toHaveBeenCalledWith(
'https://api.mail.example.com/send',
expect.objectContaining({ method: 'POST' }),
)
vi.unstubAllGlobals()
})
it('verification email HTML contains the verification URL', async () => {
const { vi } = await import('vitest')
let capturedHtml = ''
const fetchMock = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(init?.body as string)
capturedHtml = body.html
return { ok: true }
})
vi.stubGlobal('fetch', fetchMock)
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values([
{ key: 'email_provider', value: 'http' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
{ key: 'email_http_api_key', value: 'my-api-key' },
])
await signUp(ctx, 'htmltest@example.com')
await ctx.app.request('/api/auth/send-verification-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'htmltest@example.com' }),
})
expect(capturedHtml).toContain('verify-email')
expect(capturedHtml).toContain('href=')
vi.unstubAllGlobals()
})
})
describe('loadOidcConfigs — createAuth with OIDC provider pre-configured', () => {
it('createAuth succeeds when a valid enabled OIDC provider config is present', async () => {
const ctx = await createTestApp()
const oidcConfig = JSON.stringify({
providerId: 'my-oidc',
type: 'oidc',
clientId: 'client-id',
clientSecret: 'client-secret',
enabled: true,
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
scopes: ['openid', 'email'],
})
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_my-oidc', value: oidcConfig })
const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000')
expect(auth).toBeTruthy()
})
it('createAuth succeeds when a disabled OIDC provider config is present', async () => {
const ctx = await createTestApp()
const oidcConfig = JSON.stringify({
providerId: 'disabled-oidc',
type: 'oidc',
clientId: 'client-id',
clientSecret: 'client-secret',
enabled: false,
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
})
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_disabled-oidc', value: oidcConfig })
const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000')
expect(auth).toBeTruthy()
})
it('createAuth succeeds when a malformed (non-JSON) provider config row is present', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_bad', value: 'not-valid-json' })
const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000')
expect(auth).toBeTruthy()
})
})
describe('loadProviderConfig — builtin social provider resolution', () => {
it('social sign-in with an unconfigured provider returns non-200 (provider not enabled)', async () => {
const ctx = await createTestApp()
// Trigger the lazy provider resolver by initiating social sign-in.
// With no config in DB the provider returns enabled:false.
const res = await ctx.app.request('/api/auth/sign-in/social', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }),
})
// better-auth returns an error because the provider is disabled
expect(res.status).not.toBe(200)
})
it('social sign-in with a configured and enabled builtin provider returns a redirect', async () => {
const ctx = await createTestApp()
const builtinConfig = JSON.stringify({
providerId: 'github',
type: 'builtin',
clientId: 'gh-client',
clientSecret: 'gh-secret',
enabled: true,
})
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_github', value: builtinConfig })
// Trigger social sign-in — this calls the async provider loader which hits loadProviderConfig
const res = await ctx.app.request('/api/auth/sign-in/social', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }),
})
// With a valid enabled provider, better-auth returns a redirect (302) to the OAuth provider
expect([200, 302]).toContain(res.status)
})
})
describe('session hook — activeOrganizationId is set on sign-in after sign-up', () => {
it('sign-in after sign-up succeeds and returns a session cookie', async () => {
const ctx = await createTestApp()
await signUp(ctx, 'session-user@example.com')
const res = await ctx.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'session-user@example.com', password: 'password123456' }),
})
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toBeTruthy()
})
it('session record in DB has activeOrganizationId set after sign-in', async () => {
const ctx = await createTestApp()
await signUp(ctx, 'org-session@example.com')
await ctx.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'org-session@example.com', password: 'password123456' }),
})
const sessions = await ctx.db.select().from(authSchema.session)
// At least one session should have activeOrganizationId set
const withOrg = sessions.filter((s) => s.activeOrganizationId != null)
expect(withOrg.length).toBeGreaterThan(0)
})
})
describe('createPersonalOrg — org name and quota edge cases', () => {
it('sign-up with empty name creates org with fallback name "Personal Space"', async () => {
const ctx = await createTestApp()
const res = await ctx.app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '', email: 'noname@example.com', password: 'password123456' }),
})
// sign-up should succeed
expect(res.status).toBe(200)
})
it('sign-up uses a custom finite default_org_quota when set', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '524288000' })
const res = await signUp(ctx, 'quota-user@example.com')
expect(res.status).toBe(200)
})
it('sign-up falls back to DEFAULT_ORG_QUOTA when default_org_quota is non-numeric', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: 'not-a-number' })
const res = await signUp(ctx, 'quota-fallback@example.com')
expect(res.status).toBe(200)
})
it('sign-up with default_org_quota set to zero does not insert org_quota row', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' })
const res = await signUp(ctx, 'zero-quota@example.com')
expect(res.status).toBe(200)
})
})
+80 -10
View File
@@ -5,6 +5,7 @@ import { admin, organization, username } from 'better-auth/plugins'
import { genericOAuth } from 'better-auth/plugins/generic-oauth'
import { count, eq, like } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { SignupMode } from '../shared/constants'
import {
BUILTIN_PROVIDER_IDS,
OAUTH_PROVIDER_KEY_PATTERN,
@@ -14,6 +15,8 @@ import {
import * as authSchema from './db/auth-schema'
import { orgQuotas, systemOptions } from './db/schema'
import type { Database } from './platform/interface'
import { sendEmail } from './services/email'
import { redeemInviteCode } from './services/invite'
import { findPersonalOrg } from './services/org'
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
@@ -77,6 +80,42 @@ function buildDynamicSocialProviders(db: Database) {
return providers
}
async function getSignupMode(db: Database): Promise<SignupMode> {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, 'auth_signup_mode'))
const raw = rows[0]?.value
if (raw === SignupMode.INVITE_ONLY || raw === SignupMode.CLOSED) return raw
return SignupMode.OPEN
}
async function isEmailConfigured(db: Database): Promise<boolean> {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, 'email_provider'))
return !!rows[0]?.value
}
const INVITE_CODE_ERRORS: Record<string, string> = {
not_found: 'Invalid invite code',
already_used: 'Invite code already used',
expired: 'Invite code expired',
}
function buildVerificationEmailHtml(url: string): string {
if (!url.startsWith('https://') && !url.startsWith('http://')) {
throw new Error(`Verification URL has unsafe protocol: ${url}`)
}
return `<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
<h2 style="margin:0 0 16px">Verify your email</h2>
<p style="color:#555;line-height:1.5">Click the button below to verify your email address and activate your account.</p>
<a href="${url}" style="display:inline-block;margin:24px 0;padding:12px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-weight:600">Verify Email</a>
<p style="color:#999;font-size:13px">If you didn't create an account, you can safely ignore this email.</p>
</div>`
}
export async function createAuth(db: Database, secret: string, baseURL?: string, trustedOrigins?: string[]) {
const oidcConfigs = await loadOidcConfigs(db)
@@ -85,6 +124,11 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
secret,
baseURL,
trustedOrigins,
user: {
additionalFields: {
inviteCode: { type: 'string', required: false, input: true },
},
},
emailAndPassword: {
enabled: true,
password: {
@@ -92,6 +136,17 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
verify: verifyPassword,
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
if (!(await isEmailConfigured(db))) return
await sendEmail(db, {
to: user.email,
subject: 'Verify your email - ZPan',
html: buildVerificationEmailHtml(url),
})
},
autoSignInAfterVerification: true,
},
session: {
cookieCache: {
enabled: true,
@@ -116,15 +171,33 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
databaseHooks: {
user: {
create: {
// Promote the very first signup to admin BEFORE the INSERT so the
// role is baked into the session cookie that the response returns.
// Running this in `after` left the first user with a stale `user`
// role in their session cookie until they re-logged in.
before: async (user) => {
if (await isFirstUser(db)) {
return { data: { ...user, role: 'admin' } }
const firstUser = await isFirstUser(db)
// Registration gate: skip for the very first user so bootstrap works
if (!firstUser) {
const mode = await getSignupMode(db)
if (mode === SignupMode.CLOSED) {
throw new Error('Registration is currently closed')
}
if (mode === SignupMode.INVITE_ONLY) {
// better-auth passes extra sign-up body fields through to the hook
const inviteCode = (user as { inviteCode?: string }).inviteCode
if (!inviteCode) {
throw new Error('An invite code is required to register')
}
// Atomic redeem: validates and marks as used in one step,
// preventing TOCTOU races with concurrent sign-ups
const result = await redeemInviteCode(db, inviteCode, user.email)
if (result !== 'ok') {
throw new Error(INVITE_CODE_ERRORS[result] ?? 'Invalid invite code')
}
}
}
return { data: user }
// Promote the very first signup to admin BEFORE the INSERT so the
// role is baked into the session cookie that the response returns.
return { data: firstUser ? { ...user, role: 'admin' } : user }
},
after: async (user) => {
await createPersonalOrg(db, user)
@@ -133,9 +206,6 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
},
session: {
create: {
// Pin every new session to the user's personal org so routes that
// read activeOrganizationId from the cached session cookie don't
// have to fall back to a DB lookup on every request.
before: async (session) => {
const orgId = await findPersonalOrg(db, session.userId)
if (orgId) {
+11 -3
View File
@@ -1,4 +1,4 @@
import { and, count, desc, eq, isNull } from 'drizzle-orm'
import { and, count, desc, eq, gt, isNull, or } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { inviteCodes } from '../db/schema'
import type { Database } from '../platform/interface'
@@ -50,10 +50,18 @@ export async function redeemInviteCode(
const result = await db
.update(inviteCodes)
.set({ usedBy: userId, usedAt: new Date() })
.where(and(eq(inviteCodes.code, code), isNull(inviteCodes.usedBy)))
.where(
and(
eq(inviteCodes.code, code),
isNull(inviteCodes.usedBy),
or(isNull(inviteCodes.expiresAt), gt(inviteCodes.expiresAt, new Date())),
),
)
// If no rows affected, another request redeemed it concurrently
const changes = (result as { rowsAffected?: number }).rowsAffected ?? (result as { changes?: number }).changes ?? 1
const changes = (result as { rowsAffected?: number }).rowsAffected ?? (result as { changes?: number }).changes
if (changes === undefined)
throw new Error('DB driver returned no rowsAffected — cannot confirm invite code redemption')
return changes > 0 ? 'ok' : 'already_used'
}
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { DirType, ObjectStatus, StorageMode, UserRole } from './constants.js'
import { DirType, ObjectStatus, SignupMode, StorageMode, UserRole } from './constants.js'
describe('constants', () => {
it('StorageMode values', () => {
@@ -23,4 +23,20 @@ describe('constants', () => {
expect(ObjectStatus.ACTIVE).toBe('active')
expect(ObjectStatus.TRASHED).toBe('trashed')
})
it('SignupMode.OPEN equals "open"', () => {
expect(SignupMode.OPEN).toBe('open')
})
it('SignupMode.INVITE_ONLY equals "invite_only"', () => {
expect(SignupMode.INVITE_ONLY).toBe('invite_only')
})
it('SignupMode.CLOSED equals "closed"', () => {
expect(SignupMode.CLOSED).toBe('closed')
})
it('SignupMode has exactly three members', () => {
expect(Object.keys(SignupMode)).toHaveLength(3)
})
})
+8
View File
@@ -34,3 +34,11 @@ export const ObjectStatus = {
} as const
export type ObjectStatus = (typeof ObjectStatus)[keyof typeof ObjectStatus]
export const SignupMode = {
OPEN: 'open',
INVITE_ONLY: 'invite_only',
CLOSED: 'closed',
} as const
export type SignupMode = (typeof SignupMode)[keyof typeof SignupMode]