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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High README.md:194

OAuth-only accounts cannot enable TOTP, so the statement that Every account can enable it is incorrect. With allowPasswordless disabled, /two-factor/enable requires validatePassword, which returns false when no password credential exists; either support enrollment for OAuth-only users or qualify this documentation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @README.md around line 194:

OAuth-only accounts cannot enable TOTP, so the statement that `Every account can enable` it is incorrect. With `allowPasswordless` disabled, `/two-factor/enable` requires `validatePassword`, which returns false when no password credential exists; either support enrollment for OAuth-only users or qualify this documentation.

`/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/<locale>/` and shared with the marketing site; each app loads only the
scopes it renders. `aube run i18n:status` builds a
Expand Down
6 changes: 6 additions & 0 deletions apps/dashboard/app/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
269 changes: 269 additions & 0 deletions apps/dashboard/app/pages/two-factor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
<template>
<section class="panel w-full max-w-120 p-6">
<h1 class="m-0 text-lg font-650 tracking-tight">{{ $t('auth.twoFactor.title') }}</h1>

<p v-if="!ready" class="mb-0 mt-4 text-xs text-muted">
{{ $t('auth.twoFactor.loading') }}
</p>

<template v-else-if="!loggedIn">
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.challengeSubtitle') }}</p>

<div class="mt-5 grid grid-cols-2 gap-2" role="group">
<button
v-for="factor in factors"
:key="factor"
class="focus-ring h-9 border text-xs font-650 transition"
:class="
challengeFactor === factor
? 'border-accent bg-accent/8 text-ink'
: 'border-line bg-raised text-muted hover:text-ink'
"
:data-factor="factor"
type="button"
@click="challengeFactor = factor"
>
{{ $t(`auth.twoFactor.factor.${factor}`) }}
</button>
</div>

<form data-mode="challenge" class="mt-4 flex flex-col gap-3" @submit.prevent="onChallenge">
<label class="flex flex-col gap-1.5">
<span class="label-upper">
{{
challengeFactor === 'totp'
? $t('auth.twoFactor.code')
: $t('auth.twoFactor.backupCode')
}}
</span>
<input
v-model="code"
autocomplete="one-time-code"
class="input-field mono"
:inputmode="challengeFactor === 'totp' ? 'numeric' : 'text'"
required
spellcheck="false"
type="text"
/>
</label>

<label class="flex items-center gap-2 text-xs text-muted">
<input v-model="trustDevice" type="checkbox" />
{{ $t('auth.twoFactor.trustDevice') }}
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-accent" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.verifyPending') : $t('auth.twoFactor.verify') }}
</button>
</form>
</template>

<template v-else-if="twoFactorEnabled">
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.enabled') }}</p>

<form data-mode="disable" class="mt-5 flex flex-col gap-3" @submit.prevent="onDisable">
<label class="flex flex-col gap-1.5">
<span class="label-upper">{{ $t('auth.login.password') }}</span>
<input
v-model="password"
autocomplete="current-password"
class="input-field"
required
type="password"
/>
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-subtle" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.disablePending') : $t('auth.twoFactor.disable') }}
</button>
</form>
</template>

<template v-else-if="totpUri">
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.scan') }}</p>

<div class="mt-5 grid gap-5 md:grid-cols-[12rem_1fr]">
<div class="grid place-items-center border border-line bg-white p-3">
<img v-if="qrImage" class="h-44 w-44" :src="qrImage" :alt="$t('auth.twoFactor.qrAlt')" />
<code v-else class="break-all text-3xs text-canvas">{{ totpUri }}</code>
</div>

<div>
<h2 class="m-0 text-sm font-650">{{ $t('auth.twoFactor.backupCodesTitle') }}</h2>
<p class="mb-3 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.backupCodesHint') }}</p>
<ul class="m-0 grid grid-cols-2 gap-1 border border-line bg-raised p-3 list-none">
<li v-for="backupCode in backupCodes" :key="backupCode" class="mono text-xs">
{{ backupCode }}
</li>
</ul>
</div>
</div>

<form data-mode="confirm" class="mt-5 flex flex-col gap-3" @submit.prevent="onConfirm">
<label class="flex flex-col gap-1.5">
<span class="label-upper">{{ $t('auth.twoFactor.confirmCode') }}</span>
<input
v-model="code"
autocomplete="one-time-code"
class="input-field mono"
inputmode="numeric"
required
spellcheck="false"
type="text"
/>
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-accent" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.confirmPending') : $t('auth.twoFactor.confirm') }}
</button>
</form>
</template>

<template v-else>
<p class="mb-0 mt-1 text-xs text-muted">{{ $t('auth.twoFactor.disabled') }}</p>

<form data-mode="enable" class="mt-5 flex flex-col gap-3" @submit.prevent="onEnable">
<label class="flex flex-col gap-1.5">
<span class="label-upper">{{ $t('auth.login.password') }}</span>
<input
v-model="password"
autocomplete="current-password"
class="input-field"
required
type="password"
/>
</label>

<TwoFactorError :message="errorMessage" />

<button class="btn-accent" :disabled="isPending" type="submit">
{{ isPending ? $t('auth.twoFactor.enablePending') : $t('auth.twoFactor.enable') }}
</button>
</form>
</template>
</section>
</template>

<script setup lang="ts">
import QRCode from 'qrcode';
import { useI18n } from 'vue-i18n';

definePageMeta({ layout: 'auth' });

const { user, loggedIn, ready, fetchSession } = useUserSession();
const i18n = useI18n();

const factors = ['totp', 'backup'] as const;
type ChallengeFactor = (typeof factors)[number];

const challengeFactor = ref<ChallengeFactor>('totp');
const trustDevice = ref(true);
const password = ref('');
const code = ref('');
const totpUri = ref('');
const qrImage = ref('');
const backupCodes = ref<string[]>([]);
const isPending = ref(false);
const errorMessage = ref<string>();

const twoFactorEnabled = computed(() =>
Boolean(user.value && 'twoFactorEnabled' in user.value && user.value.twoFactorEnabled),
);

function authClient() {
const client = useAuthClient();
if (!client) throw new Error('auth client unavailable');
return client;
}

function responseFailed(result: { error?: unknown }): boolean {
if (!result.error) return false;
errorMessage.value = i18n.t('auth.twoFactor.error');
return true;
}

async function onChallenge(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const value = code.value.trim();
const result =
challengeFactor.value === 'totp'
? await authClient().twoFactor.verifyTotp({ code: value, trustDevice: trustDevice.value })
: await authClient().twoFactor.verifyBackupCode({
code: value,
trustDevice: trustDevice.value,
});
if (!responseFailed(result)) await navigateTo('/');
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}

async function onEnable(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const result = await authClient().twoFactor.enable({ password: password.value });
if (responseFailed(result) || !result.data) return;

totpUri.value = result.data.totpURI;
backupCodes.value = result.data.backupCodes;
password.value = '';
qrImage.value = await QRCode.toDataURL(result.data.totpURI, {
errorCorrectionLevel: 'M',
margin: 1,
width: 352,
});
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}

async function onConfirm(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const result = await authClient().twoFactor.verifyTotp({ code: code.value.trim() });
if (responseFailed(result)) return;
await fetchSession({ force: true });
totpUri.value = '';
qrImage.value = '';
backupCodes.value = [];
code.value = '';
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}

async function onDisable(): Promise<void> {
if (isPending.value) return;
isPending.value = true;
errorMessage.value = undefined;
try {
const result = await authClient().twoFactor.disable({ password: password.value });
if (responseFailed(result)) return;
password.value = '';
await fetchSession({ force: true });
} catch {
errorMessage.value = i18n.t('auth.twoFactor.error');
} finally {
isPending.value = false;
}
}
</script>
13 changes: 13 additions & 0 deletions apps/dashboard/modules/auth/components/TwoFactorError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<p
v-if="message"
class="m-0 border border-danger/35 bg-danger/8 p-2.5 text-xs text-danger"
role="alert"
>
{{ message }}
</p>
</template>

<script setup lang="ts">
defineProps<{ message?: string }>();
</script>
3 changes: 3 additions & 0 deletions apps/dashboard/modules/auth/components/UserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<p class="m-0 mt-1 truncate text-xs font-650" :title="user?.email">
{{ user?.name || user?.email }}
</p>
<NuxtLink class="btn-link mt-2.5 block text-xs" to="/two-factor">
{{ $t('auth.twoFactor.link') }}
</NuxtLink>
<button
class="focus-ring mt-2.5 h-8 w-full border border-line bg-raised text-xs text-ink font-650 transition hover:border-muted"
type="button"
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ export default defineNuxtConfig({
// than being shown the form. The auth layout, not the shell, because the visitor arriving here
// typed a code off a terminal and has no business in the navigation.
'/device': { auth: { only: 'user' } },
// Serves both authenticated enrollment and the pre-session challenge after password sign-in.
// The Better Auth endpoints enforce the relevant cookie/session for each operation.
'/two-factor': { auth: false },
// Reached from an invitation email, so the visitor is frequently signed out at that moment:
// requiring a session sends them through /login and back, rather than rejecting the link.
'/organizations/accept-invitation/**': { appLayout: 'default', auth: { only: 'user' } },
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@
"@tanstack/vue-query": "^5.101.4",
"@tanstack/vue-table": "^9.1.2",
"better-auth": "^1.6.26",
"drizzle-orm": "^0.45.2",
"evlog": "^2.26.0",
"h3": "^1.15.11",
"nuxt": "^4.5.2",
"qrcode": "^1.5.4",
"vite-hub": "^0.0.3",
"vue": "^3.5.40",
"vue-i18n": "^11.4.8"
Expand All @@ -56,6 +58,7 @@
"@nuxtjs/color-mode": "4.0.1",
"@nuxtjs/i18n": "^10.6.0",
"@playwright/test": "^1.62.0",
"@types/qrcode": "^1.5.6",
"@unocss/nuxt": "^66.7.5",
"@unocss/preset-wind4": "^66.7.5",
"@vue/test-utils": "^2.4.6",
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/server/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export default defineServerAuth(
invite: [],
inviteUse: [],
deviceCode: [],
twoFactor: [],
}),
// Test-only, and deliberately added here rather than in `packages/auth`: `testUtils`
// registers no HTTP route, but it hangs privileged helpers off the auth context that can
Expand Down
22 changes: 22 additions & 0 deletions apps/dashboard/test/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ test.describe('Authentication', () => {
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
});

test('offers two-factor setup to a signed-in operator', async ({ page, goto }) => {
await mockAuthSession(page, true);

await goto('/two-factor', { waitUntil: 'networkidle' });

await expect(
page.getByRole('heading', { name: 'Two-factor authentication', level: 1 }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Set up two-factor authentication' }),
).toBeVisible();
});

test('shows the second-factor challenge before a session exists', async ({ page, goto }) => {
await mockAuthSession(page, false);

await goto('/two-factor', { waitUntil: 'networkidle' });

await expect(page.getByRole('button', { name: 'Verify and sign in' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Backup code' })).toBeVisible();
});

test('hides disabled sign-in methods on the login page', async ({ page, goto }) => {
await mockAuthSession(page, false);

Expand Down
Loading
Loading