Skip to content

Commit 4e0fdb9

Browse files
committed
clean(GH-1782): harden parseJwt validation and error handling
Validate JWT structure, apply base64url padding before atob, and throw JwtParseError with clear messages when decoding or JSON parsing fails.
1 parent eb78fd4 commit 4e0fdb9

2 files changed

Lines changed: 168 additions & 13 deletions

File tree

zmscitizenview/src/utils/auth.ts

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,104 @@ import { ref } from "vue";
33
import { useDBSLoginWebcomponentPlugin } from "@/components/DBSLoginWebcomponentPlugin";
44
import AuthorizationEventDetails from "@/types/AuthorizationEventDetails";
55

6-
function parseJwt(token: string) {
7-
const base64Url = token.split(".")[1];
6+
class JwtParseError extends Error {
7+
constructor(message: string, options?: { cause?: unknown }) {
8+
super(message);
9+
this.name = "JwtParseError";
10+
if (options?.cause !== undefined) {
11+
this.cause = options.cause;
12+
}
13+
}
14+
}
15+
16+
function getJwtPayloadSegment(token: string): string {
17+
if (!token?.trim()) {
18+
throw new JwtParseError("Invalid JWT: token must be a non-empty string");
19+
}
20+
21+
const parts = token.split(".");
22+
if (parts.length !== 3) {
23+
throw new JwtParseError(
24+
`Invalid JWT: expected 3 dot-separated segments, got ${parts.length}`
25+
);
26+
}
27+
28+
const payloadSegment = parts[1];
29+
if (!payloadSegment) {
30+
throw new JwtParseError("Invalid JWT: payload segment is missing");
31+
}
32+
33+
return payloadSegment;
34+
}
35+
36+
function base64UrlToBase64(base64Url: string): string {
837
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
9-
const jsonPayload = decodeURIComponent(
10-
window
11-
.atob(base64)
12-
.split("")
13-
.map(function (c) {
14-
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
15-
})
16-
.join("")
17-
);
18-
return JSON.parse(jsonPayload);
38+
const paddingLength = (4 - (base64.length % 4)) % 4;
39+
return base64 + "=".repeat(paddingLength);
40+
}
41+
42+
function decodeJwtPayloadSegment(payloadSegment: string): string {
43+
const base64 = base64UrlToBase64(payloadSegment);
44+
45+
let decoded: string;
46+
try {
47+
decoded = window.atob(base64);
48+
} catch (error) {
49+
throw new JwtParseError("Invalid JWT: failed to base64-decode payload", {
50+
cause: error,
51+
});
52+
}
53+
54+
try {
55+
return decodeURIComponent(
56+
decoded
57+
.split("")
58+
.map((character) => {
59+
return "%" + ("00" + character.charCodeAt(0).toString(16)).slice(-2);
60+
})
61+
.join("")
62+
);
63+
} catch (error) {
64+
throw new JwtParseError("Invalid JWT: failed to decode payload bytes", {
65+
cause: error,
66+
});
67+
}
68+
}
69+
70+
function parseJwt(token: string): Record<string, unknown> {
71+
const payloadSegment = getJwtPayloadSegment(token);
72+
const jsonPayload = decodeJwtPayloadSegment(payloadSegment);
73+
74+
try {
75+
const parsed: unknown = JSON.parse(jsonPayload);
76+
if (
77+
parsed === null ||
78+
typeof parsed !== "object" ||
79+
Array.isArray(parsed)
80+
) {
81+
throw new JwtParseError("Invalid JWT: payload must be a JSON object");
82+
}
83+
return parsed as Record<string, unknown>;
84+
} catch (error) {
85+
if (error instanceof JwtParseError) {
86+
throw error;
87+
}
88+
throw new JwtParseError("Invalid JWT: payload is not valid JSON", {
89+
cause: error,
90+
});
91+
}
1992
}
2093

2194
export function getTokenData(accessToken: string): {
2295
email?: string;
2396
given_name?: string;
2497
family_name?: string;
2598
} {
26-
return parseJwt(accessToken);
99+
return parseJwt(accessToken) as {
100+
email?: string;
101+
given_name?: string;
102+
family_name?: string;
103+
};
27104
}
28105

29106
export function useLogin() {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { getTokenData } from "@/utils/auth";
4+
5+
function createJwt(payload: Record<string, unknown>): string {
6+
const json = JSON.stringify(payload);
7+
const base64 = btoa(json);
8+
const base64Url = base64
9+
.replace(/\+/g, "-")
10+
.replace(/\//g, "_")
11+
.replace(/=+$/, "");
12+
return `eyJhbGciOiJIUzI1NiJ9.${base64Url}.signature`;
13+
}
14+
15+
describe("getTokenData / parseJwt", () => {
16+
it("parses a valid JWT payload", () => {
17+
const token = createJwt({
18+
email: "user@example.com",
19+
given_name: "Max",
20+
family_name: "Mustermann",
21+
});
22+
23+
expect(getTokenData(token)).toEqual({
24+
email: "user@example.com",
25+
given_name: "Max",
26+
family_name: "Mustermann",
27+
});
28+
});
29+
30+
it("handles base64url payloads that require padding", () => {
31+
const token = createJwt({ sub: "123" });
32+
33+
expect(getTokenData(token)).toEqual({ sub: "123" });
34+
});
35+
36+
it("rejects tokens without three segments", () => {
37+
expect(() => getTokenData("only-one-segment")).toThrow(
38+
"Invalid JWT: expected 3 dot-separated segments, got 1"
39+
);
40+
expect(() => getTokenData("a.b")).toThrow(
41+
"Invalid JWT: expected 3 dot-separated segments, got 2"
42+
);
43+
});
44+
45+
it("rejects tokens with an empty payload segment", () => {
46+
expect(() => getTokenData("header..signature")).toThrow(
47+
"Invalid JWT: payload segment is missing"
48+
);
49+
});
50+
51+
it("rejects empty tokens", () => {
52+
expect(() => getTokenData("")).toThrow(
53+
"Invalid JWT: token must be a non-empty string"
54+
);
55+
});
56+
57+
it("rejects invalid base64 payload", () => {
58+
expect(() => getTokenData("a.!!!.c")).toThrow(
59+
"Invalid JWT: failed to base64-decode payload"
60+
);
61+
});
62+
63+
it("rejects non-JSON payload", () => {
64+
const notJson = btoa("not-json").replace(/\+/g, "-").replace(/\//g, "_");
65+
expect(() => getTokenData(`a.${notJson}.c`)).toThrow(
66+
"Invalid JWT: payload is not valid JSON"
67+
);
68+
});
69+
70+
it("rejects JSON payload that is not an object", () => {
71+
const arrayPayload = btoa("[1,2,3]")
72+
.replace(/\+/g, "-")
73+
.replace(/\//g, "_");
74+
expect(() => getTokenData(`a.${arrayPayload}.c`)).toThrow(
75+
"Invalid JWT: payload must be a JSON object"
76+
);
77+
});
78+
});

0 commit comments

Comments
 (0)