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
4 changes: 3 additions & 1 deletion clients/admin/src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions clients/admin/tests/auth/jwt-utf8.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
6 changes: 5 additions & 1 deletion clients/admin/tests/helpers/auth-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export type SeededUser = {

function fakeJwt(payload: Record<string, unknown>): 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(".");
}

Expand Down
4 changes: 3 additions & 1 deletion clients/dashboard/src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions clients/dashboard/tests/auth/jwt-utf8.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 7 additions & 3 deletions clients/dashboard/tests/helpers/auth-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): 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(".");
}

Expand Down
Loading