fix(work-orders): trust only JWT org_scope for account list

Ignore stored AuthUser.orgScope so a scoped token cannot open
the unscoped account picker via localStorage.
This commit is contained in:
Arthur Bassi 2026-08-25 21:31:18 -03:00
parent 2bff57fd02
commit 4f16b6a76f
4 changed files with 24 additions and 13 deletions

View file

@ -7,7 +7,7 @@ export interface AuthUser {
fullname: string;
id: string | number;
name?: string;
/** JWT / login `org_scope`. Only `all` may see org-wide account lists. */
/** Login payload field. Not trusted for org-wide UI gates; use the JWT claim. */
orgScope?: string;
accountId?: string | number;
}

View file

@ -33,13 +33,12 @@ function readStringClaim(source: Record<string, unknown> | null, keys: string[])
return "";
}
/** Org-wide only when the authenticated scope is explicitly `all`. Fail-closed otherwise. */
/** Org-wide only when the JWT claim is explicitly `all`. Fail-closed otherwise. */
export function isOrgWideScope(user: AuthUser | null | undefined): boolean {
if (!user) {
if (!user || typeof user.token !== "string") {
return false;
}
const claims = typeof user.token === "string" ? readJwtClaims(user.token) : null;
const fromJwt = readStringClaim(claims, ["org_scope", "orgScope", "OrgScope"]);
const fromUser = typeof user.orgScope === "string" ? user.orgScope.trim() : "";
return (fromJwt || fromUser).toLowerCase() === ORG_SCOPE_ALL;
const claims = readJwtClaims(user.token);
const scope = readStringClaim(claims, ["org_scope", "orgScope", "OrgScope"]);
return scope.toLowerCase() === ORG_SCOPE_ALL;
}

View file

@ -50,7 +50,7 @@ function encodeJwt(payload: Record<string, unknown>): string {
return `eyJhbGciOiJub25lIn0.${b64}.sig`;
}
function authUser(token: string): AuthUser {
function authUser(token: string, orgScope?: string): AuthUser {
return {
token,
expiration: new Date(Date.now() + 60_000).toISOString(),
@ -59,6 +59,7 @@ function authUser(token: string): AuthUser {
phoneNumber: "",
fullname: "Test User",
id: 1,
orgScope,
};
}
@ -132,4 +133,12 @@ describe("WizardStepLocationAccountSelect", () => {
expect(screen.queryByText(FOREIGN_ACCOUNT)).not.toBeInTheDocument();
expect(screen.queryByText("Amazon")).not.toBeInTheDocument();
});
it("keeps the list disabled when a scoped token has no org_scope and storage says all", () => {
renderSelect(authUser(encodeJwt({ account_id: 7 }), "all"));
expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, false);
expect(screen.queryByRole("button", { name: /select account/i })).not.toBeInTheDocument();
expect(screen.queryByText(FOREIGN_ACCOUNT)).not.toBeInTheDocument();
});
});

View file

@ -36,16 +36,19 @@ describe("isOrgWideScope", () => {
expect(isOrgWideScope(user(encodeJwt({ org_scope: "account", account_id: 7 })))).toBe(false);
});
it("uses the login orgScope when the token is not a JWT", () => {
expect(isOrgWideScope(user("session-token", "all"))).toBe(true);
expect(isOrgWideScope(user("session-token", "account"))).toBe(false);
it("ignores a stored orgScope when the token is not a JWT", () => {
expect(isOrgWideScope(user("session-token", "all"))).toBe(false);
});
it("prefers a scoped JWT over a stale org-wide login field", () => {
it("ignores a stale stored orgScope when the JWT has no org_scope", () => {
expect(isOrgWideScope(user(encodeJwt({ account_id: 7 }), "all"))).toBe(false);
});
it("ignores a stale stored orgScope when the JWT is scoped", () => {
expect(isOrgWideScope(user(encodeJwt({ org_scope: "account" }), "all"))).toBe(false);
});
it("is false when neither the JWT nor the user declares all", () => {
it("is false when the token has no org_scope claim", () => {
expect(isOrgWideScope(user("wo-visual-token"))).toBe(false);
});
});