Skip to content

feat(auth): show a QR code during TOTP enrolment - #2269

Merged
cevheri merged 2 commits into
libredb:mainfrom
khaledCSE:feat/1826-totp-enrolment-qr
Nov 8, 2026
Merged

cevheri merged 2 commits into
libredb:mainfrom
khaledCSE:feat/1826-totp-enrolment-qr

Conversation

@khaledCSE

@khaledCSE khaledCSE commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1826

Description

Problem

Setting up an authenticator for a server-store account meant typing the key or opening the otpauth:// link on the same device. beginTotpEnrolment (src/lib/local-accounts.ts:860-872) already returns otpauthUrl to the browser, so the missing piece was rendering, not data. docs/MFA.md also said Studio never mints a QR, which is only true for the environment-variable path.

Approach

In step 1 of src/components/auth/AuthenticatorSettings.tsx I render QRCodeSVG (qrcode.react 4.2.0) from the otpauthUrl returned by begin, only when it is present. It sits in a bg-white p-3 container, black on white, with role="img" and the label "Scan to add LibreDB to your authenticator app". The key, Copy button and setup link are unchanged. The hint mentions scanning only when a QR is shown. No new endpoint, network request or stored state.

docs/MFA.md now scopes the "does not mint one" paragraph to the environment-variable path and says server-store accounts get a QR under Sign-in security.

sequenceDiagram
    participant U as User
    participant C as AuthenticatorSettings
    participant R as POST /api/auth/totp
    participant L as beginTotpEnrolment
    U->>C: password, Set up authenticator
    C->>R: action begin
    R->>L: email, body
    L-->>R: secret, otpauthUrl
    R-->>C: secret, otpauthUrl
    C->>C: render key, link, QRCodeSVG(otpauthUrl)
    U->>C: Confirm code or Cancel
    C->>C: enrolment state cleared, QR unmounted
Loading

Design decisions & trade-offs

  • Server URL, not a rebuilt one. The QR uses the server's otpauthUrl as asked, so the QR and the key cannot disagree.
  • SVG, not canvas. It stays sharp at any zoom and needs no canvas.
  • Fixed white container instead of theme colours. Scanners need dark-on-light with a quiet zone, including in dark mode.
  • Lint suppression. role="img" on the SVG needs oxlint-disable-next-line jsx-a11y/prefer-tag-over-role. This matches 8 existing jsx-a11y suppressions in src/.
  • Decoding is proven in e2e, not in component tests. happy-dom returns null for getContext("2d"). The component tests mock qrcode.react with a stub that forwards props and adds no role or label, so those attributes must come from the component.
  • Duplicated sign-in helpers. e2e/totp-enrolment-qr.spec.ts repeats the sign-in helpers from e2e/passkey.spec.ts instead of extracting them, which keeps an unrelated spec out of this diff. I can extract them in a follow-up.

Type of Change

  • New feature
  • Documentation update

Testing

New tests in tests/components/auth/AuthenticatorSettings.test.tsx, written first and failing before the change:

  • passes the begin otpauth url to the QR renderer
  • exposes the enrolment QR as an image with an accessible name and a light quiet-zone container
  • omits the QR when the server sends no otpauth url but still shows the key
  • clears the enrolment QR after the code is confirmed
  • clears the enrolment QR when setup is cancelled

e2e/totp-enrolment-qr.spec.ts runs on the chromium-passkey project. It screenshots the QR and decodes the PNG in Node with jsqr and pngjs (e2e/helpers/decode-enrolment-qr-png.ts). It asserts the result equals the page's otpauth link, once with <html> confirmed light and once confirmed dark.

Results:

  • bun tests/run-tests.ts tests/components/auth/AuthenticatorSettings.test.tsx: 15 pass.
  • bunx playwright test --project=chromium-passkey e2e/totp-enrolment-qr.spec.ts: 2 passed on 93b2444c, rebased onto e6852bc7. Its webServer next build succeeded.
  • bun run test:coverage in a Linux container: AuthenticatorSettings.tsx has 293 of 293 lines covered.
    • coverage:check there flagged 40 lines in files this PR does not touch: sqlite.ts, duckdb/index.ts, instrumentation.ts and unwritable-file.ts.
    • main CI passes this gate, so I expect CI here to pass too.
  • My Windows and container runs had suite failures (symlinks, tar, TLS, gosu). They reproduce with identical counts on upstream/main.
  • format, lint, typecheck, knip, chart:check, channels:showcase:check, readme:check and security:check all exit 0.

Test Environment

  • Version: 1.2.0
  • Browser: Chromium (Playwright 1.64.1)
  • OS: Windows (win32-x64); Linux via mcr.microsoft.com/playwright:v1.64.1-noble
  • Runtime: Bun 1.4.4
  • Database: SQLite server store (STORAGE_PROVIDER=sqlite)

Screenshots

Enrolment step in light and dark mode. The key, link and QR are blurred, and I checked that neither QR decodes.

Image Image

Risk & Rollback

The change is client-only. The QR encodes the same value the page already shows as the setup link, so nothing new is exposed. The new runtime dependency is qrcode.react, which has no transitive dependencies in bun.lock. jsqr, pngjs and @types/pngjs are dev-only. To roll back, revert the two commits. There is no migration or config change.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​pngjs@​6.0.51001007080100
Addednpm/​jsqr@​1.4.010010010075100
Addednpm/​qrcode.react@​4.2.010010010080100
Addednpm/​pngjs@​7.0.010010010080100

View full report

@codecov

codecov Bot commented Nov 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri
cevheri merged commit d1e6d73 into libredb:main Nov 8, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] registering a TOTP authenticator using a QR Code

2 participants