Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f309809eba | ||
|
|
12696657a0 |
@@ -4,6 +4,18 @@ All notable changes are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.3.2 - 2026-10-04
|
||||
|
||||
- Added an explicit Show QR code action for the current credential and each imported token, opening a large, camera-readable enrollment QR in a keyboard-accessible dialog.
|
||||
- Kept enrollment QR codes black on white in both themes, with a quiet zone, token identity and parameters, local SVG download, and visible errors when a token exceeds the encoder capacity.
|
||||
- Replaced the automatic credential QR preview with deliberate display and verified displayed TOTP/HOTP enrollment codes with the independent jsQR decoder.
|
||||
|
||||
## 0.3.1 - 2026-09-01
|
||||
|
||||
- Corrected the packaged corresponding-source identity to name this release and its immutable source tag.
|
||||
- Serialized the unit-test worker pool so release verification remains reliable on shared filesystems.
|
||||
- Normalized ZIP creation to UTC so release bytes remain reproducible across host timezones.
|
||||
|
||||
## 0.3.0 - 2026-08-19
|
||||
|
||||
- Added a live WebAuthn Extensions Lab for `credProps`, PRF, `largeBlob`, `appid` and `appidExclude`, including client-capability detection and normalized request/result inspection.
|
||||
|
||||
@@ -8,6 +8,7 @@ Authentication material stays in the active tab. The application has no backend,
|
||||
|
||||
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
|
||||
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
|
||||
- **Show QR code** in the credential editor or beside any imported token displays a large enrollment QR for scanning with another device's authenticator. The dialog identifies the token and its parameters, supports SVG download, and closes with Escape. Displaying or downloading it reveals the token's secret; generation stays entirely in this tab.
|
||||
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
|
||||
- Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics.
|
||||
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
|
||||
@@ -54,7 +55,7 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
|
||||
|
||||
## Static deployment
|
||||
|
||||
Serve `dist/` or the contents of `release/auth-tools-0.3.0.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
|
||||
Serve `dist/` or the contents of `release/auth-tools-0.3.2.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
|
||||
|
||||
```text
|
||||
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Corresponding source and provenance
|
||||
|
||||
The corresponding source for OTP & Passkey Tools 0.2.0 is published at:
|
||||
The corresponding source for OTP & Passkey Tools 0.3.2 is published at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.2.0
|
||||
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.2
|
||||
|
||||
Build that tag with Node.js 22 and the exact `package-lock.json`:
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "auth-tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auth-tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auth-tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"description": "Inspect and test OTP and WebAuthn authentication locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
|
||||
@@ -4,6 +4,18 @@ All notable changes are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.3.2 - 2026-10-04
|
||||
|
||||
- Added an explicit Show QR code action for the current credential and each imported token, opening a large, camera-readable enrollment QR in a keyboard-accessible dialog.
|
||||
- Kept enrollment QR codes black on white in both themes, with a quiet zone, token identity and parameters, local SVG download, and visible errors when a token exceeds the encoder capacity.
|
||||
- Replaced the automatic credential QR preview with deliberate display and verified displayed TOTP/HOTP enrollment codes with the independent jsQR decoder.
|
||||
|
||||
## 0.3.1 - 2026-09-01
|
||||
|
||||
- Corrected the packaged corresponding-source identity to name this release and its immutable source tag.
|
||||
- Serialized the unit-test worker pool so release verification remains reliable on shared filesystems.
|
||||
- Normalized ZIP creation to UTC so release bytes remain reproducible across host timezones.
|
||||
|
||||
## 0.3.0 - 2026-08-19
|
||||
|
||||
- Added a live WebAuthn Extensions Lab for `credProps`, PRF, `largeBlob`, `appid` and `appidExclude`, including client-capability detection and normalized request/result inspection.
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ Authentication material stays in the active tab. The application has no backend,
|
||||
|
||||
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
|
||||
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
|
||||
- **Show QR code** in the credential editor or beside any imported token displays a large enrollment QR for scanning with another device's authenticator. The dialog identifies the token and its parameters, supports SVG download, and closes with Escape. Displaying or downloading it reveals the token's secret; generation stays entirely in this tab.
|
||||
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
|
||||
- Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics.
|
||||
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
|
||||
@@ -54,7 +55,7 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
|
||||
|
||||
## Static deployment
|
||||
|
||||
Serve `dist/` or the contents of `release/auth-tools-0.3.0.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
|
||||
Serve `dist/` or the contents of `release/auth-tools-0.3.2.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
|
||||
|
||||
```text
|
||||
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Corresponding source and provenance
|
||||
|
||||
The corresponding source for OTP & Passkey Tools 0.2.0 is published at:
|
||||
The corresponding source for OTP & Passkey Tools 0.3.2 is published at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.2.0
|
||||
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.2
|
||||
|
||||
Build that tag with Node.js 22 and the exact `package-lock.json`:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.auth-tools",
|
||||
"name": "OTP & Passkey Tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
@@ -126,7 +126,11 @@ try {
|
||||
stagedArchive,
|
||||
...sourceFiles.map((file) => file.relative),
|
||||
],
|
||||
{ cwd: stagedTree, maxBuffer: 1024 * 1024 },
|
||||
{
|
||||
cwd: stagedTree,
|
||||
env: { ...process.env, TZ: "UTC" },
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const archive = await readFile(stagedArchive);
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
} from "../../otp/vendor-backups";
|
||||
import { exportEncryptedPskc, importEncryptedPskc } from "../../otp/pskc";
|
||||
import { OtpAdvancedTools } from "./OtpAdvancedTools";
|
||||
import { TokenQrDialog } from "./TokenQrDialog";
|
||||
|
||||
type TotpTimeMode = "current" | "relative" | "absolute";
|
||||
|
||||
@@ -118,6 +119,7 @@ export function OtpWorkspace() {
|
||||
bytesToBase32(profile.secret),
|
||||
);
|
||||
const [secretVisible, setSecretVisible] = useState(false);
|
||||
const [qrProfile, setQrProfile] = useState<OtpProfile | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [totpTimeMode, setTotpTimeMode] = useState<TotpTimeMode>("current");
|
||||
const [periodOffset, setPeriodOffset] = useState("0");
|
||||
@@ -955,7 +957,6 @@ export function OtpWorkspace() {
|
||||
)}
|
||||
{uri ? (
|
||||
<>
|
||||
<div className="qr" dangerouslySetInnerHTML={{ __html: qr }} />
|
||||
<label className="wide">
|
||||
<span>Provisioning URI</span>
|
||||
<textarea
|
||||
@@ -969,6 +970,13 @@ export function OtpWorkspace() {
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => setQrProfile(profile)}
|
||||
>
|
||||
Show QR code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
@@ -979,6 +987,7 @@ export function OtpWorkspace() {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!qr}
|
||||
onClick={() =>
|
||||
downloadText("otp-provisioning.svg", qr, "image/svg+xml")
|
||||
}
|
||||
@@ -1144,9 +1153,13 @@ export function OtpWorkspace() {
|
||||
</div>
|
||||
<div className="credential-list">
|
||||
{imported.map((item, index) => (
|
||||
<div
|
||||
className="credential-list-row"
|
||||
key={`${item.issuer}-${item.account}-${index}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.issuer}-${item.account}-${index}`}
|
||||
className="credential-select"
|
||||
onClick={() => {
|
||||
setProfile(item);
|
||||
setSecretText(bytesToBase32(item.secret));
|
||||
@@ -1156,10 +1169,19 @@ export function OtpWorkspace() {
|
||||
<strong>{item.issuer || "No issuer"}</strong>
|
||||
<span>{item.account}</span>
|
||||
<small>
|
||||
{item.kind.toUpperCase()} · {item.algorithm} · {item.digits}{" "}
|
||||
digits
|
||||
{item.kind.toUpperCase()} · {item.algorithm} ·{" "}
|
||||
{item.digits} digits
|
||||
</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
aria-label={`Show QR code for ${item.issuer ? `${item.issuer}: ` : ""}${item.account}`}
|
||||
onClick={() => setQrProfile(item)}
|
||||
>
|
||||
Show QR code
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{warnings.length > 0 && (
|
||||
@@ -1291,6 +1313,9 @@ export function OtpWorkspace() {
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
{qrProfile && (
|
||||
<TokenQrDialog profile={qrProfile} onClose={() => setQrProfile(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useId, useMemo, useRef } from "react";
|
||||
import { serializeOtpAuth, type OtpProfile } from "../../otp/profile";
|
||||
import { qrSvg } from "../../qr/encoder";
|
||||
|
||||
export function TokenQrDialog({
|
||||
profile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: OtpProfile;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const result = useMemo(() => {
|
||||
try {
|
||||
return { svg: qrSvg(serializeOtpAuth(profile)), error: "" };
|
||||
} catch (reason) {
|
||||
return {
|
||||
svg: "",
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Could not create QR code.",
|
||||
};
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
}, []);
|
||||
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([result.svg], { type: "image/svg+xml" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "otp-provisioning.svg";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="tool-dialog token-qr-dialog"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Scan with your authenticator</p>
|
||||
<h2 id={titleId}>OTP token QR code</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close QR code"
|
||||
onClick={onClose}
|
||||
autoFocus
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<p className="token-qr-account">
|
||||
<strong>{profile.issuer || "No issuer"}</strong>
|
||||
<span>{profile.account}</span>
|
||||
</p>
|
||||
<p className="hint" id={descriptionId}>
|
||||
Scan this QR code on another device to add this token. It contains the
|
||||
token’s secret, so anyone who scans it can generate its codes.
|
||||
</p>
|
||||
{result.error ? (
|
||||
<p className="inline-error" role="alert">
|
||||
{result.error} Use Copy URI or a text export for this token instead.
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="qr token-qr-display"
|
||||
dangerouslySetInnerHTML={{ __html: result.svg }}
|
||||
/>
|
||||
)}
|
||||
<p className="hint token-qr-parameters">
|
||||
{profile.kind.toUpperCase()} · {profile.algorithm} · {profile.digits}{" "}
|
||||
digits ·{" "}
|
||||
{profile.kind === "totp"
|
||||
? `${profile.period}-second period`
|
||||
: `counter ${profile.counter}`}
|
||||
</p>
|
||||
{profile.kind === "totp" && (profile.epoch ?? 0) !== 0 && (
|
||||
<p className="hint">
|
||||
This token uses a non-zero T0. Some authenticators ignore that
|
||||
parameter and will generate different codes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!result.svg}
|
||||
onClick={download}
|
||||
>
|
||||
Download QR
|
||||
</button>
|
||||
<button type="button" className="primary-button" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
+33
-3
@@ -547,17 +547,26 @@ button:disabled {
|
||||
max-height: 28rem;
|
||||
overflow: auto;
|
||||
}
|
||||
.credential-list button {
|
||||
.credential-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-right: 1rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
.credential-list .credential-select {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: transparent;
|
||||
color: var(--toolbox-text);
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.credential-list button:hover {
|
||||
.credential-list .credential-select:hover {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.credential-list span,
|
||||
@@ -918,6 +927,27 @@ button:disabled {
|
||||
background: rgb(10 15 30 / 55%);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.token-qr-dialog {
|
||||
width: min(calc(100% - 2rem), 38rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
}
|
||||
.token-qr-account {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.token-qr-display svg {
|
||||
display: block;
|
||||
width: min(100%, 28rem);
|
||||
aspect-ratio: 1;
|
||||
background: white;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
.token-qr-parameters {
|
||||
text-align: center;
|
||||
}
|
||||
.dialog-heading,
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.auth-tools",
|
||||
"name": "OTP & Passkey Tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APPLICATION_VERSION = "0.3.0";
|
||||
export const APPLICATION_VERSION = "0.3.2";
|
||||
|
||||
@@ -1,4 +1,90 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import jsQR from "jsqr";
|
||||
import { bytesToBase32 } from "../../src/crypto/encoding";
|
||||
import { parseOtpAuth } from "../../src/otp/profile";
|
||||
|
||||
test("displays scannable imported TOTP and HOTP tokens on a dark mobile screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => requests.push(request.url()));
|
||||
await page.goto("/deep/nested/auth/");
|
||||
await page.getByRole("button", { name: "Import & migration" }).click();
|
||||
await page
|
||||
.getByPlaceholder("Paste migration data. It never leaves this tab.")
|
||||
.fill(
|
||||
"otpauth://totp/Ex%C3%A4mple:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=Ex%C3%A4mple&algorithm=SHA256&digits=8&period=45\n" +
|
||||
"otpauth://hotp/Other:bob?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Other&counter=18446744073709551615",
|
||||
);
|
||||
await page.getByRole("button", { name: "Inspect import" }).click();
|
||||
|
||||
for (const account of ["Exämple: alice@example.test", "Other: bob"]) {
|
||||
const trigger = page.getByRole("button", {
|
||||
name: `Show QR code for ${account}`,
|
||||
exact: true,
|
||||
});
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: "OTP token QR code" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const svg = dialog.getByRole("img", { name: "OTP provisioning QR code" });
|
||||
await expect(svg).toBeVisible();
|
||||
const bounds = await svg.boundingBox();
|
||||
expect(bounds!.width).toBeGreaterThan(250);
|
||||
expect(bounds!.height).toBeCloseTo(bounds!.width, 0);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
|
||||
const pixels = await svg.evaluate(async (element) => {
|
||||
const source = new XMLSerializer().serializeToString(element);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([source], { type: "image/svg+xml" }),
|
||||
);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 640;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(image, 0, 0, 640, 640);
|
||||
return Array.from(context.getImageData(0, 0, 640, 640).data);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
expect(pixels.slice(0, 4)).toEqual([255, 255, 255, 255]);
|
||||
const decoded = jsQR(new Uint8ClampedArray(pixels), 640, 640);
|
||||
expect(decoded).not.toBeNull();
|
||||
const { profile } = parseOtpAuth(decoded!.data);
|
||||
if (account.startsWith("Exämple")) {
|
||||
expect(profile).toMatchObject({
|
||||
kind: "totp",
|
||||
issuer: "Exämple",
|
||||
account: "alice@example.test",
|
||||
algorithm: "SHA-256",
|
||||
digits: 8,
|
||||
period: 45,
|
||||
});
|
||||
expect(bytesToBase32(profile.secret)).toBe("JBSWY3DPEHPK3PXP");
|
||||
} else {
|
||||
expect(profile).toMatchObject({
|
||||
kind: "hotp",
|
||||
issuer: "Other",
|
||||
account: "bob",
|
||||
counter: 18446744073709551615n,
|
||||
});
|
||||
expect(bytesToBase32(profile.secret)).toBe(
|
||||
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
|
||||
);
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
expect(
|
||||
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("runs from a nested path and keeps authentication material local", async ({
|
||||
page,
|
||||
@@ -13,7 +99,10 @@ test("runs from a nested path and keeps authentication material local", async ({
|
||||
await page.getByLabel("Issuer").fill("Example");
|
||||
await page.getByLabel("Account").fill("alice@example.test");
|
||||
await expect(page.getByText("TOTP code")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Show QR code", exact: true }).click();
|
||||
await expect(page.locator(".qr svg")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
expect(
|
||||
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
|
||||
).toBe(true);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TokenQrDialog } from "../../src/components/otp/TokenQrDialog";
|
||||
import { parseOtpAuth } from "../../src/otp/profile";
|
||||
|
||||
describe("token QR display", () => {
|
||||
it("reports encoder limits without displaying an empty QR or allowing download", () => {
|
||||
const { profile } = parseOtpAuth(
|
||||
`otpauth://totp/${"a".repeat(500)}?secret=JBSWY3DPEHPK3PXP`,
|
||||
);
|
||||
render(<TokenQrDialog profile={profile} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/version 10/iu);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Download QR" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("identifies nonportable time parameters without showing the plaintext seed", () => {
|
||||
const { profile } = parseOtpAuth(
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&t0=100",
|
||||
);
|
||||
render(<TokenQrDialog profile={profile} onClose={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText(/Some authenticators ignore/iu),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog").textContent).not.toContain(
|
||||
"JBSWY3DPEHPK3PXP",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ export default defineConfig({
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
css: true,
|
||||
restoreMocks: true,
|
||||
pool: "forks",
|
||||
maxWorkers: 1,
|
||||
exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user