From 364cbc2aee9dcc9ab6ada5985a1e8710b95e50b9 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Mon, 13 Apr 2026 11:19:54 -0400 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.6 --- server/auth.test.ts | 375 ++++++++++++++++++++++++++++++++++++++ server/auth.ts | 90 ++++++++- server/services/invite.ts | 14 +- shared/constants.test.ts | 18 +- shared/constants.ts | 8 + 5 files changed, 491 insertions(+), 14 deletions(-) create mode 100644 server/auth.test.ts diff --git a/server/auth.test.ts b/server/auth.test.ts new file mode 100644 index 00000000..552d429e --- /dev/null +++ b/server/auth.test.ts @@ -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> + +async function signUp(ctx: TestCtx, email: string, extra?: Record) { + 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) + }) +}) diff --git a/server/auth.ts b/server/auth.ts index d440ef02..ba714057 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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 { + 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 { + 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 = { + 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 `
+

Verify your email

+

Click the button below to verify your email address and activate your account.

+Verify Email +

If you didn't create an account, you can safely ignore this email.

+
` +} + 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) { diff --git a/server/services/invite.ts b/server/services/invite.ts index e3e843ad..9d4e1245 100644 --- a/server/services/invite.ts +++ b/server/services/invite.ts @@ -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' } diff --git a/shared/constants.test.ts b/shared/constants.test.ts index 2d99f573..3fc122d5 100644 --- a/shared/constants.test.ts +++ b/shared/constants.test.ts @@ -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) + }) }) diff --git a/shared/constants.ts b/shared/constants.ts index 12c99e88..2ac40fb0 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -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]