From fad036ef6c2cf727d7b3e1d021a44e14527665bc Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 24 Aug 2026 14:24:04 +0200 Subject: [PATCH 1/3] feat(auth): add two-factor authentication --- README.md | 7 + apps/dashboard/app/auth.config.ts | 6 + apps/dashboard/app/pages/two-factor.vue | 269 ++++ .../auth/components/TwoFactorError.vue | 13 + .../modules/auth/components/UserMenu.vue | 3 + apps/dashboard/nuxt.config.ts | 3 + apps/dashboard/package.json | 3 + apps/dashboard/server/auth.config.ts | 1 + apps/dashboard/test/e2e/auth.spec.ts | 22 + .../test/nuxt/components/UserMenu.spec.ts | 9 + .../test/nuxt/pages/two-factor.spec.ts | 112 ++ docs/architecture.md | 12 +- packages/auth/package.json | 1 + packages/auth/src/auth.test.ts | 9 + packages/auth/src/auth.ts | 7 + .../0004_outstanding_mister_sinister.sql | 16 + .../database/drizzle/meta/0004_snapshot.json | 1313 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/client.test.ts | 1 + packages/database/src/schema/auth.test.ts | 29 + packages/database/src/schema/auth.ts | 34 +- packages/database/src/schema/index.ts | 5 +- packages/i18n/locales/en/auth.json | 29 + packages/i18n/locales/it/auth.json | 29 + packages/i18n/schemas/auth.schema.json | 87 ++ pnpm-lock.yaml | 167 ++- 26 files changed, 2183 insertions(+), 11 deletions(-) create mode 100644 apps/dashboard/app/pages/two-factor.vue create mode 100644 apps/dashboard/modules/auth/components/TwoFactorError.vue create mode 100644 apps/dashboard/test/nuxt/pages/two-factor.spec.ts create mode 100644 packages/database/drizzle/0004_outstanding_mister_sinister.sql create mode 100644 packages/database/drizzle/meta/0004_snapshot.json create mode 100644 packages/database/src/schema/auth.test.ts diff --git a/README.md b/README.md index 1e21d59..ce08a6d 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,13 @@ change them. The flip side of that build-time capture is a deployment contract: those policy variables, rebuild the app, or the auth pages will keep advertising the old capabilities (the server still enforces its own policy either way). +Every account can enable TOTP two-factor authentication from the signed-in user menu. Setup at +`/two-factor` requires the account password, displays a QR code plus one-time backup codes, and +does not become active until the first authenticator code verifies. The same route handles the +second-factor challenge after password sign-in, including backup-code recovery and an optional +30-day trusted-device cookie. Apply the checked-in database migrations before deploying this +feature: the Better Auth plugin adds `user.two_factor_enabled` and the `two_factor` table. + The interface ships English and Italian through `@nuxtjs/i18n`, with dictionaries split by scope in `packages/i18n/locales//` and shared with the marketing site; each app loads only the scopes it renders. `aube run i18n:status` builds a diff --git a/apps/dashboard/app/auth.config.ts b/apps/dashboard/app/auth.config.ts index 5446f9d..355a01c 100644 --- a/apps/dashboard/app/auth.config.ts +++ b/apps/dashboard/app/auth.config.ts @@ -6,6 +6,7 @@ import { lastLoginMethodClient, multiSessionClient, organizationClient, + twoFactorClient, } from 'better-auth/client/plugins'; // Better Auth is mounted in this app's own server (`server/auth.config.ts`), so every request is @@ -39,6 +40,11 @@ export default defineClientAuth(() => { betterEnrollmentClient(), lastLoginMethodClient(), multiSessionClient(), + twoFactorClient({ + onTwoFactorRedirect: async () => { + await navigateTo('/two-factor'); + }, + }), deviceAuthorizationClient(), dashClient(), // The one entry that is gated, and the only one whose absence changes nothing about the diff --git a/apps/dashboard/app/pages/two-factor.vue b/apps/dashboard/app/pages/two-factor.vue new file mode 100644 index 0000000..2804971 --- /dev/null +++ b/apps/dashboard/app/pages/two-factor.vue @@ -0,0 +1,269 @@ + + + diff --git a/apps/dashboard/modules/auth/components/TwoFactorError.vue b/apps/dashboard/modules/auth/components/TwoFactorError.vue new file mode 100644 index 0000000..7577a33 --- /dev/null +++ b/apps/dashboard/modules/auth/components/TwoFactorError.vue @@ -0,0 +1,13 @@ + + + diff --git a/apps/dashboard/modules/auth/components/UserMenu.vue b/apps/dashboard/modules/auth/components/UserMenu.vue index 1479c85..e635a5e 100644 --- a/apps/dashboard/modules/auth/components/UserMenu.vue +++ b/apps/dashboard/modules/auth/components/UserMenu.vue @@ -6,6 +6,9 @@

{{ user?.name || user?.email }}

+ + {{ $t('auth.twoFactor.link') }} + diff --git a/apps/dashboard/config/env.ts b/apps/dashboard/config/env.ts index df37eb2..f0a4403 100644 --- a/apps/dashboard/config/env.ts +++ b/apps/dashboard/config/env.ts @@ -109,3 +109,16 @@ export const viteHubVercelEntryName = '__server.func'; export function viteHubVercelEntryAlias(serverDirectory: string): string { return join(dirname(serverDirectory), viteHubVercelEntryName); } + +// Aube can leave optional peer links inside Better Auth's adapter package pointing at a virtual +// store entry it did not materialize. Nitro's node-file trace follows that dead link even though +// the dashboard owns a valid `drizzle-orm` dependency, failing the build before it can package the +// server. Keeping this narrow dependency chain in the bundle bypasses tracing for those package +// IDs while every unrelated server dependency remains external and traceable. +const authServerDependency = + /^(?:better-auth(?:\/|$)|@better-auth\/drizzle-adapter(?:\/|$)|drizzle-orm(?:\/|$))/u; + +/** Returns whether Nitro must bundle an auth dependency instead of tracing it as an external. */ +export function shouldInlineAuthServerDependency(id: string): boolean { + return authServerDependency.test(id); +} diff --git a/apps/dashboard/nuxt.config.ts b/apps/dashboard/nuxt.config.ts index 2358c78..fd112a0 100644 --- a/apps/dashboard/nuxt.config.ts +++ b/apps/dashboard/nuxt.config.ts @@ -9,7 +9,11 @@ import { authConfigFromEnvironment, infraFromEnvironment } from '@agent-zero/aut import { defaultLocale, i18nLocalesFor, localeCookieName } from '@agent-zero/i18n'; import { defineNuxtConfig } from 'nuxt/config'; -import { viteHubPresetFromEnvironment, viteHubVercelEntryAlias } from './config/env.js'; +import { + shouldInlineAuthServerDependency, + viteHubPresetFromEnvironment, + viteHubVercelEntryAlias, +} from './config/env.js'; // Resolved once at config evaluation so the dashboard's auth pages publish the same sign-in // policy `server/auth.config.ts` enforces at runtime (AUTH_ENABLE_SIGNUP, GitHub OAuth @@ -191,6 +195,9 @@ export default defineNuxtConfig({ }, nitro: { + externals: { + inline: [shouldInlineAuthServerDependency], + }, // Registered as a Nitro module rather than through `nitro.hooks`: a handler under that key // replaces the preset's own handler for the same hook, and the `vercel` preset writes // `config.json` and each function's `.vc-config.json` from its `compiled` hook — losing it diff --git a/apps/dashboard/test/unit/env.test.ts b/apps/dashboard/test/unit/env.test.ts index c2f5e15..860f953 100644 --- a/apps/dashboard/test/unit/env.test.ts +++ b/apps/dashboard/test/unit/env.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { defaultViteHubPreset, + shouldInlineAuthServerDependency, viteHubPresetFromEnvironment, viteHubVercelEntryAlias, viteHubVercelEntryName, @@ -88,3 +89,15 @@ describe('viteHubVercelEntryAlias', () => { ); }); }); + +describe('shouldInlineAuthServerDependency', () => { + it('keeps the Better Auth Drizzle chain out of Nitro dependency tracing', () => { + expect(shouldInlineAuthServerDependency('better-auth/adapters/drizzle')).toBe(true); + expect(shouldInlineAuthServerDependency('@better-auth/drizzle-adapter')).toBe(true); + expect(shouldInlineAuthServerDependency('drizzle-orm/pg-core')).toBe(true); + expect(shouldInlineAuthServerDependency('@better-auth/infra')).toBe(false); + expect(shouldInlineAuthServerDependency('@better-auth/core')).toBe(false); + expect(shouldInlineAuthServerDependency('better-call')).toBe(false); + expect(shouldInlineAuthServerDependency('@agent-zero/database')).toBe(false); + }); +}); From 5c4142e09034fc04ab39fa4bca6b1a43f4cd42bc Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 27 Aug 2026 13:40:59 +0200 Subject: [PATCH 3/3] fix(dashboard): skip dangling auth peer trace --- apps/dashboard/config/env.ts | 20 +++++++++---------- apps/dashboard/nuxt.config.ts | 6 ++++-- apps/dashboard/test/unit/env.test.ts | 29 ++++++++++++++++++---------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/config/env.ts b/apps/dashboard/config/env.ts index f0a4403..7af72ee 100644 --- a/apps/dashboard/config/env.ts +++ b/apps/dashboard/config/env.ts @@ -110,15 +110,15 @@ export function viteHubVercelEntryAlias(serverDirectory: string): string { return join(dirname(serverDirectory), viteHubVercelEntryName); } -// Aube can leave optional peer links inside Better Auth's adapter package pointing at a virtual -// store entry it did not materialize. Nitro's node-file trace follows that dead link even though -// the dashboard owns a valid `drizzle-orm` dependency, failing the build before it can package the -// server. Keeping this narrow dependency chain in the bundle bypasses tracing for those package -// IDs while every unrelated server dependency remains external and traceable. -const authServerDependency = - /^(?:better-auth(?:\/|$)|@better-auth\/drizzle-adapter(?:\/|$)|drizzle-orm(?:\/|$))/u; +// Aube can leave this optional peer link inside Better Auth's adapter package pointing at a +// virtual-store entry it did not materialize. Nitro's node-file trace records the dangling path, +// then fails while canonicalising every recorded reason even though `drizzle-orm` is also traced +// through the database package's valid dependency. Ignore only that nested link: the valid package +// and every other auth dependency remain external and are still copied into the server output. +const brokenAuthDrizzlePeerLink = + /(?:^|\/)\.aube\/@better-auth\+drizzle-adapter@[^/]+\/node_modules\/drizzle-orm(?:\/|$)/u; -/** Returns whether Nitro must bundle an auth dependency instead of tracing it as an external. */ -export function shouldInlineAuthServerDependency(id: string): boolean { - return authServerDependency.test(id); +/** Returns whether Nitro's file trace should skip Aube's dangling optional peer link. */ +export function shouldIgnoreBrokenAuthPeerLink(path: string): boolean { + return brokenAuthDrizzlePeerLink.test(path.replaceAll('\\', '/')); } diff --git a/apps/dashboard/nuxt.config.ts b/apps/dashboard/nuxt.config.ts index fd112a0..9738902 100644 --- a/apps/dashboard/nuxt.config.ts +++ b/apps/dashboard/nuxt.config.ts @@ -10,7 +10,7 @@ import { defaultLocale, i18nLocalesFor, localeCookieName } from '@agent-zero/i18 import { defineNuxtConfig } from 'nuxt/config'; import { - shouldInlineAuthServerDependency, + shouldIgnoreBrokenAuthPeerLink, viteHubPresetFromEnvironment, viteHubVercelEntryAlias, } from './config/env.js'; @@ -196,7 +196,9 @@ export default defineNuxtConfig({ nitro: { externals: { - inline: [shouldInlineAuthServerDependency], + traceOptions: { + ignore: shouldIgnoreBrokenAuthPeerLink, + }, }, // Registered as a Nitro module rather than through `nitro.hooks`: a handler under that key // replaces the preset's own handler for the same hook, and the `vercel` preset writes diff --git a/apps/dashboard/test/unit/env.test.ts b/apps/dashboard/test/unit/env.test.ts index 860f953..0e3d53e 100644 --- a/apps/dashboard/test/unit/env.test.ts +++ b/apps/dashboard/test/unit/env.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { defaultViteHubPreset, - shouldInlineAuthServerDependency, + shouldIgnoreBrokenAuthPeerLink, viteHubPresetFromEnvironment, viteHubVercelEntryAlias, viteHubVercelEntryName, @@ -90,14 +90,23 @@ describe('viteHubVercelEntryAlias', () => { }); }); -describe('shouldInlineAuthServerDependency', () => { - it('keeps the Better Auth Drizzle chain out of Nitro dependency tracing', () => { - expect(shouldInlineAuthServerDependency('better-auth/adapters/drizzle')).toBe(true); - expect(shouldInlineAuthServerDependency('@better-auth/drizzle-adapter')).toBe(true); - expect(shouldInlineAuthServerDependency('drizzle-orm/pg-core')).toBe(true); - expect(shouldInlineAuthServerDependency('@better-auth/infra')).toBe(false); - expect(shouldInlineAuthServerDependency('@better-auth/core')).toBe(false); - expect(shouldInlineAuthServerDependency('better-call')).toBe(false); - expect(shouldInlineAuthServerDependency('@agent-zero/database')).toBe(false); +describe('shouldIgnoreBrokenAuthPeerLink', () => { + it('only skips the adapter peer link that Aube can leave dangling', () => { + expect( + shouldIgnoreBrokenAuthPeerLink( + 'node_modules/.aube/@better-auth+drizzle-adapter@1.6.26_hash/node_modules/drizzle-orm', + ), + ).toBe(true); + expect( + shouldIgnoreBrokenAuthPeerLink( + String.raw`node_modules\.aube\@better-auth+drizzle-adapter@1.6.26_hash\node_modules\drizzle-orm\package.json`, + ), + ).toBe(true); + expect(shouldIgnoreBrokenAuthPeerLink('node_modules/drizzle-orm/package.json')).toBe(false); + expect( + shouldIgnoreBrokenAuthPeerLink( + 'node_modules/.aube/@better-auth+drizzle-adapter@1.6.26_hash/node_modules/better-auth', + ), + ).toBe(false); }); });