Skip to content
Merged
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
59 changes: 59 additions & 0 deletions .changeset/two-factor-backup-code-reveal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
"@objectstack/platform-objects": patch
---

Show 2FA backup codes on the surface a user can actually reach — the reachable
regeneration path was a lockout (#10681).

`sys_user.generate_backup_codes` is mounted at Setup → People & Organization →
Users (Security tab, via `record:quick_actions { location: 'record_section' }`
in `pages/sys-user.page.ts`). It declared no `resultDialog`: it toasted "New
backup codes generated — save them somewhere safe", issued the request, and
dropped the response. The previous code set is invalidated wholesale the moment
that request succeeds, so the reachable path was *old codes destroyed, new codes
discarded* — with no way to get them back:

- better-auth's `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` and
`auth-manager.ts` passes no `backupCodeOptions`, so `sys_two_factor.backup_codes`
holds `symmetricEncrypt(JSON.stringify(codes))` — one opaque ciphertext.
- `auth-route-ledger.ts` publishes `generate-backup-codes` and **no** route that
reads codes back. There is no re-reveal endpoint, by design.

So the API response is the user's one and only sight of those codes.
`generate_backup_codes` now declares the one-shot reveal
(`{ path: 'backupCodes', format: 'code-list' }`) and `enable_two_factor` the QR
equivalent (`totpURI` as `qrcode` + `backupCodes`), which suppresses the toast
and opens an acknowledge-only dialog instead. Both copy the shapes
`sys_two_factor.enable_two_factor` / `regenerate_backup_codes` already carried —
deliberately not a third and fourth spelling of the same declaration.

**Why the correct declarations existed and still did not help.** `sys_two_factor`
carries them and is mounted in **no** app — it appears in no navigation
contribution — so the only 2FA surface a user can reach was the one missing them.
That is why the new pin
(`packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts`)
walks the Setup-navigation → page → quick-actions → action chain rather than
asserting a key is present, and holds coverage over a **derived** set: every
identity action targeting a route known to return an unrecoverable secret must
reveal it. A fifth 2FA surface added later is held to the same rule with no edit
to the test. It also fails a `successMessage` declared alongside a
`resultDialog` — the toast is suppressed, so such a message is dead text, and in
this case it was the very string that made the defect look handled.

The declaration-to-response join is measured over a booted stack in
`packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts`: the
declared paths are resolved against the live route's real response, because a
path that stops matching better-auth's response shape opens an **empty** dialog
and loses the codes just as thoroughly, while every declaration-shape assertion
stays green.

**Also corrected, same area:** `sys_two_factor.backup_codes` was described as
"JSON-serialized backup recovery codes". It is JSON *before* encryption; what the
column stores is the ciphertext above. The description now says so, since the
whole reason the reveal must happen at generation time is that this column
cannot be read back.

**Not addressed here:** mounting `sys_two_factor` into navigation is a larger
product-surface decision and is only raised, not taken; `#10700` (re-enrolment
rotating the TOTP secret while keeping `verified=1`) is a separate defect and
remains open.
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,11 +267,19 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
enable_two_factor: {
label: "Enable Two-Factor Auth",
successMessage: "Two-factor authentication enabled. Scan the QR code or paste the otpauth URI into your authenticator app, then verify a code to complete setup.",
params: {
password: {
label: "Current Password"
}
},
resultDialog: {
title: "Two-factor authentication enabled",
description: "Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.",
acknowledge: "I have saved my backup codes",
fields: {
totpURI: "Authenticator URI",
backupCodes: "Backup Codes"
}
}
},
disable_two_factor: {
Expand All@@ -287,11 +295,18 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
generate_backup_codes: {
label: "Regenerate Backup Codes",
description: "Generate a new set of backup codes? Any previously generated codes will stop working.",
successMessage: "New backup codes generated — save them somewhere safe.",
params: {
password: {
label: "Current Password"
}
},
resultDialog: {
title: "New backup codes generated",
description: "Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.",
acknowledge: "I have saved the new codes",
fields: {
backupCodes: "Backup Codes"
}
}
}
}
Expand DownExpand Up@@ -1030,7 +1045,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
backup_codes: {
label: "Backup Codes",
help: "JSON-serialized backup recovery codes"
help: "Backup recovery codes, encrypted at rest (a single opaque ciphertext, not readable JSON)"
},
verified: {
label: "Verified",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,11 +267,19 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
enable_two_factor: {
label: "Habilitar autenticación de dos factores",
successMessage: "Autenticación de dos factores habilitada. Escanea el código QR o pega el URI otpauth en tu aplicación de autenticación y verifica un código para completar la configuración.",
params: {
password: {
label: "Contraseña actual"
}
},
resultDialog: {
title: "Autenticación de doble factor habilitada",
description: "Escanee el código QR con su aplicación de autenticación y guarde los códigos de respaldo en un lugar seguro. Los códigos de respaldo se muestran una sola vez.",
acknowledge: "He guardado mis códigos de respaldo",
fields: {
totpURI: "URI del autenticador",
backupCodes: "Códigos de respaldo"
}
}
},
disable_two_factor: {
Expand All@@ -287,11 +295,18 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
generate_backup_codes: {
label: "Regenerar códigos de respaldo",
description: "¿Generar un nuevo juego de códigos de respaldo? Los códigos generados anteriormente dejarán de funcionar.",
successMessage: "Nuevos códigos de respaldo generados; guárdalos en un lugar seguro.",
params: {
password: {
label: "Contraseña actual"
}
},
resultDialog: {
title: "Nuevos códigos de respaldo generados",
description: "Los códigos de respaldo anteriores ya no son válidos. Guarde estos nuevos códigos en un lugar seguro: se muestran una sola vez.",
acknowledge: "He guardado los nuevos códigos",
fields: {
backupCodes: "Códigos de respaldo"
}
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,11 +267,19 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
enable_two_factor: {
label: "二要素認証を有効化",
successMessage: "二要素認証を有効にしました。認証アプリで QR コードをスキャンするか otpauth URI を貼り付け、コードを検証して設定を完了してください。",
params: {
password: {
label: "現在のパスワード"
}
},
resultDialog: {
title: "二要素認証を有効化しました",
description: "認証アプリで QR コードをスキャンし、バックアップコードを安全な場所に保存してください。バックアップコードの表示は一度きりです。",
acknowledge: "バックアップコードを保存しました",
fields: {
totpURI: "認証アプリ URI",
backupCodes: "バックアップコード"
}
}
},
disable_two_factor: {
Expand All@@ -287,11 +295,18 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
generate_backup_codes: {
label: "バックアップコードを再生成",
description: "新しいバックアップコードを生成しますか?以前に生成されたコードはすべて使用できなくなります。",
successMessage: "新しいバックアップコードを生成しました。安全な場所に保管してください。",
params: {
password: {
label: "現在のパスワード"
}
},
resultDialog: {
title: "新しいバックアップコードを生成しました",
description: "以前のバックアップコードは無効になりました。新しいコードを安全な場所に保存してください。表示は一度きりです。",
acknowledge: "新しいコードを保存しました",
fields: {
backupCodes: "バックアップコード"
}
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,11 +267,19 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
enable_two_factor: {
label: "启用双因素认证",
successMessage: "双因素认证已启用。用身份验证器 App 扫描二维码或粘贴 otpauth URI,然后验证一次动态码以完成设置。",
params: {
password: {
label: "当前密码"
}
},
resultDialog: {
title: "双因素认证已启用",
description: "请用身份验证器应用扫描二维码,并将备用码妥善保存。备用码只显示一次。",
acknowledge: "我已保存备用码",
fields: {
totpURI: "身份验证器 URI",
backupCodes: "备用码"
}
}
},
disable_two_factor: {
Expand All@@ -287,11 +295,18 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
generate_backup_codes: {
label: "重新生成备用码",
description: "要生成一组新的备用码吗?之前生成的备用码将全部失效。",
successMessage: "新备用码已生成——请妥善保存。",
params: {
password: {
label: "当前密码"
}
},
resultDialog: {
title: "已生成新的备用码",
description: "之前的备用码已全部失效。请妥善保存这些新备用码——它们只显示一次。",
acknowledge: "我已保存新备用码",
fields: {
backupCodes: "备用码"
}
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,15 @@ export const SysTwoFactor = ObjectSchema.create({
backup_codes: Field.textarea({
label: 'Backup Codes',
required: false,
description: 'JSON-serialized backup recovery codes',
// NOT JSON at rest, despite what this said until #10681. better-auth's
// `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` and we pass
// no `backupCodeOptions`, so `encodeBackupCodes` JSON-stringifies the
// codes and then `symmetricEncrypt`s that string with the auth secret:
// the column holds ONE opaque ciphertext, not a readable array. Reading
// the row back therefore reveals nothing — which is why the codes have
// to be shown at generation time (#10681) and why no re-reveal route
// exists to add.
description: 'Backup recovery codes, encrypted at rest (a single opaque ciphertext, not readable JSON)',
}),

verified: Field.boolean({
Expand Down
51 changes: 44 additions & 7 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -393,11 +393,25 @@ export const SysUser = ObjectSchema.create({
],
},
// ── Two-factor authentication ─────────────────────────────────
// Enable flow returns { totpURI, backupCodes } — surfacing those
// safely needs a QR + verify UI that the generic action engine
// can't render yet. We still expose it so the API call works
// and the success toast displays the otpauth:// URI that users
// can manually add to an authenticator app as a fallback.
// These three are the ONLY 2FA surface a user can actually reach: the
// `sys_two_factor` variants that carry the same declarations are mounted
// in no app (`setup-nav.contributions.ts` has no `sys_two_factor` entry),
// so this record page — Setup → People & Organization → Users, Security
// tab, via `record:quick_actions { location: 'record_section' }` in
// `pages/sys-user.page.ts` — is where these run in production.
//
// Both `enable` and `generate-backup-codes` return values that CANNOT be
// retrieved afterwards: better-auth mints backup codes and stores only
// `symmetricEncrypt(JSON.stringify(codes))` (its `twoFactor()` default is
// `storeBackupCodes: 'encrypted'`, which we do not override), and it
// publishes no re-reveal route — `auth-route-ledger.ts` lists
// `generate-backup-codes` and nothing that reads them back. So the
// response is the user's ONE sight of these values, and a `successMessage`
// toast discards them: the old codes are already dead by then, which turns
// the reachable path into a lockout. `resultDialog` is what makes the
// one-shot reveal an actual reveal — the runtime suppresses the toast and
// opens an acknowledge-only dialog instead. Same shape as the
// `sys_two_factor` declarations; deliberately not a fourth spelling.
{
name: 'enable_two_factor',
label: 'Enable Two-Factor Auth',
Expand All@@ -408,11 +422,21 @@ export const SysUser = ObjectSchema.create({
target: '/api/v1/auth/two-factor/enable',
visible: 'has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true',
requiresFeature: 'twoFactor',
successMessage: 'Two-factor authentication enabled. Scan the QR code or paste the otpauth URI into your authenticator app, then verify a code to complete setup.',
refreshAfter: true,
params: [
{ name: 'password', label: 'Current Password', type: 'text', required: true },
],
// No `successMessage`: `resultDialog` suppresses it, so one declared
// here would be unreachable text that still ships to every translator.
resultDialog: {
title: 'Two-factor authentication enabled',
description: 'Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.',
acknowledge: 'I have saved my backup codes',
fields: [
{ path: 'totpURI', label: 'Authenticator URI', format: 'qrcode' },
{ path: 'backupCodes', label: 'Backup Codes', format: 'code-list' },
],
},
},
{
name: 'disable_two_factor',
Expand DownExpand Up@@ -443,12 +467,25 @@ export const SysUser = ObjectSchema.create({
visible: 'has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true',
requiresFeature: 'twoFactor',
// Confirm question on `description` — one dialog, not two (#7278/#7309).
// The confirm dialog and the reveal dialog are NOT that pair: they are
// two different moments (decide, then save), which is exactly the shape
// `sys_two_factor.regenerate_backup_codes` already carries.
description: 'Generate a new set of backup codes? Any previously generated codes will stop working.',
successMessage: 'New backup codes generated — save them somewhere safe.',
refreshAfter: false,
params: [
{ name: 'password', label: 'Current Password', type: 'text', required: true },
],
// No `successMessage` — see the note above `enable_two_factor`. The toast
// this replaces ("New backup codes generated — save them somewhere safe")
// told the user to save codes it never showed them.
resultDialog: {
title: 'New backup codes generated',
description: 'Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.',
acknowledge: 'I have saved the new codes',
fields: [
{ path: 'backupCodes', label: 'Backup Codes', format: 'code-list' },
],
},
},
],

Expand Down
Loading
Loading