diff --git a/clients/admin/src/auth/jwt.ts b/clients/admin/src/auth/jwt.ts index 4390ba4a3d..4c18a57eb7 100644 --- a/clients/admin/src/auth/jwt.ts +++ b/clients/admin/src/auth/jwt.ts @@ -15,7 +15,9 @@ export function decodeJwt(token: string | null | undefined): JwtClaims | null { try { const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4); - const json = atob(padded); + // atob yields one char per byte; the payload is UTF-8, so decode the bytes + // or "Comitê" renders as "Comitê". + const json = new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))); return JSON.parse(json) as JwtClaims; } catch { return null; diff --git a/clients/admin/tests/auth/jwt-utf8.spec.ts b/clients/admin/tests/auth/jwt-utf8.spec.ts new file mode 100644 index 0000000000..2e77c05b98 --- /dev/null +++ b/clients/admin/tests/auth/jwt-utf8.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installAdminShellMocks, ADMIN_PERMS } from "../helpers/shell-mocks"; + +// Regression: the JWT payload is UTF-8. Decoding it with a bare atob() read the +// bytes as Latin-1, so a claim like "Comitê de Ética" rendered as "Comitê". + +test("renders non-ASCII name claims from the access token intact", async ({ page }) => { + await seedAuthedSession(page, { + ...TEST_USER, + firstName: "Comitê", + lastName: "de Ética", + permissions: [...ADMIN_PERMS], + }); + await installAdminShellMocks(page); + + await page.goto("/"); + + await expect(page.getByText(/Comitê/).first()).toBeVisible(); + await expect(page.getByText(/Ã/)).toHaveCount(0); +}); diff --git a/clients/admin/tests/helpers/auth-seed.ts b/clients/admin/tests/helpers/auth-seed.ts index c631a344ac..09a38f2da5 100644 --- a/clients/admin/tests/helpers/auth-seed.ts +++ b/clients/admin/tests/helpers/auth-seed.ts @@ -16,7 +16,11 @@ export type SeededUser = { function fakeJwt(payload: Record): string { const b64url = (obj: unknown) => - btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + // UTF-8 bytes, like the server emits, so non-ASCII claims hit the real decode path. + btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(obj)))) + .replace(/=+$/, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); } diff --git a/clients/dashboard/src/auth/jwt.ts b/clients/dashboard/src/auth/jwt.ts index 811b19dd81..2bd60e37d5 100644 --- a/clients/dashboard/src/auth/jwt.ts +++ b/clients/dashboard/src/auth/jwt.ts @@ -31,7 +31,9 @@ export function decodeJwt(token: string | null | undefined): JwtClaims | null { try { const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4); - const json = atob(padded); + // atob yields one char per byte; the payload is UTF-8, so decode the bytes + // or "Comitê" renders as "Comitê". + const json = new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))); return JSON.parse(json) as JwtClaims; } catch { return null; diff --git a/clients/dashboard/tests/auth/jwt-utf8.spec.ts b/clients/dashboard/tests/auth/jwt-utf8.spec.ts new file mode 100644 index 0000000000..3b099dcfda --- /dev/null +++ b/clients/dashboard/tests/auth/jwt-utf8.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { installShellMocks } from "../helpers/shell-mocks"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; + +// Regression: the JWT payload is UTF-8. Decoding it with a bare atob() read the +// bytes as Latin-1, so a claim like "Comitê de Ética" rendered as +// "Comitê de Ã…tica" in the user menu and the overview greeting. + +test("renders non-ASCII name claims from the access token intact", async ({ page }) => { + await seedAuthedSession(page, { ...TEST_USER, firstName: "Comitê", lastName: "de Ética" }); + await installShellMocks(page); + + await page.goto("/"); + + await expect(page.getByText("Comitê de Ética").first()).toBeVisible(); + await expect(page.getByText(/Ã/)).toHaveCount(0); +}); diff --git a/clients/dashboard/tests/helpers/auth-seed.ts b/clients/dashboard/tests/helpers/auth-seed.ts index 3eaf4bd166..19b95541a2 100644 --- a/clients/dashboard/tests/helpers/auth-seed.ts +++ b/clients/dashboard/tests/helpers/auth-seed.ts @@ -33,12 +33,16 @@ const TENANT_KEY = "fsh.dashboard.tenant"; /** * Encode a minimal JWT (header.payload.signature) where every segment is - * base64url-encoded JSON. Signature is a junk string — the dashboard - * never validates it (only the server does), so this is safe. + * base64url-encoded UTF-8 JSON, like the server emits (so non-ASCII claims + * such as "Comitê de Ética" exercise the real decode path). Signature is a + * junk string — the dashboard never validates it (only the server does). */ function fakeJwt(payload: Record): string { const b64url = (obj: unknown) => - btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(obj)))) + .replace(/=+$/, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); }