From a83b0b8a1e92bdaac7ba10eca3e261f67305be99 Mon Sep 17 00:00:00 2001 From: Cu Thanh Cam Date: Sun, 5 Jul 2026 16:51:49 +0700 Subject: [PATCH 1/2] Enhance JWT decoder service --- .../jwt-decoder/jwt-decoder.service.test.ts | 81 ++- .../jwt-decoder/jwt-decoder.service.ts | 581 +++++++++++++++++- 2 files changed, 650 insertions(+), 12 deletions(-) diff --git a/src/features/jwt-decoder/jwt-decoder.service.test.ts b/src/features/jwt-decoder/jwt-decoder.service.test.ts index 5c0eeef..1462945 100644 --- a/src/features/jwt-decoder/jwt-decoder.service.test.ts +++ b/src/features/jwt-decoder/jwt-decoder.service.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { hasJwtDecoderInput, normalizeJwtDecoderInput } from "./jwt-decoder.service"; +import { + decodeJwt, + generateJwtExample, + getJwtBreakdownRows, + hasJwtDecoderInput, + normalizeJwtDecoderInput, + verifyJwtSignature, +} from "./jwt-decoder.service"; + +const hs256Token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmZvcmdlLmxvY2FsIiwiYXVkIjoiZm9yZ2UtYXBpIiwiaWF0IjoxNzE5ODAwMDAwLCJuYmYiOjE3MTk4MDAwMDAsImV4cCI6MjUzMjQwMDAwMH0.I5QWq5bQ8YPsUn740ax_QE1XVNXAHf171NukCN5PEN8"; describe("jwt-decoder service", () => { it("normalizes user input", () => { @@ -9,4 +19,73 @@ describe("jwt-decoder service", () => { it("detects empty input", () => { expect(hasJwtDecoderInput(" ")).toBe(false); }); + + it("decodes header, payload and registered claims", () => { + const decoded = decodeJwt(hs256Token, new Date("2026-01-01T00:00:00.000Z")); + + expect(decoded.error).toBeUndefined(); + expect(decoded.algorithm).toBe("HS256"); + expect(decoded.header).toContain('"typ": "JWT"'); + expect(decoded.payload).toContain('"sub": "user_42"'); + expect(decoded.claims.find((claim) => claim.name === "exp")?.status).toBe("active"); + }); + + it("reports malformed tokens", () => { + const decoded = decodeJwt("abc.def"); + + expect(decoded.error).toBe("JWT must contain exactly three dot-separated sections."); + }); + + it("warns about unsigned tokens and missing validation claims", () => { + const decoded = decodeJwt( + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzQyIn0.", + ); + + expect(decoded.warnings.some((warning) => warning.title === "Unsigned token")).toBe( + true, + ); + expect( + decoded.warnings.some((warning) => warning.title === "No expiration claim"), + ).toBe(true); + }); + + it("verifies HS256 signatures locally", async () => { + await expect(verifyJwtSignature(hs256Token, "forge-secret")).resolves.toMatchObject({ + status: "verified", + }); + await expect(verifyJwtSignature(hs256Token, "wrong-secret")).resolves.toMatchObject({ + status: "failed", + }); + }); + + it("generates signed HMAC examples", async () => { + const token = await generateJwtExample("HS384", "forge-secret"); + const decoded = decodeJwt(token); + + expect(decoded.algorithm).toBe("HS384"); + await expect(verifyJwtSignature(token, "forge-secret")).resolves.toMatchObject({ + status: "verified", + }); + }); + + it("generates inspectable asymmetric examples", async () => { + const token = await generateJwtExample("RS256", "forge-secret"); + const decoded = decodeJwt(token); + + expect(decoded.algorithm).toBe("RS256"); + await expect(verifyJwtSignature(token, "forge-secret")).resolves.toMatchObject({ + status: "unsupported", + }); + }); + + it("builds breakdown rows for decoded sections", () => { + const decoded = decodeJwt(hs256Token); + const headerRows = getJwtBreakdownRows(decoded.headerJson, "header"); + const payloadRows = getJwtBreakdownRows(decoded.payloadJson, "payload"); + + expect(headerRows.find((row) => row.name === "alg")?.description).toContain( + "Algorithm", + ); + expect(payloadRows.find((row) => row.name === "exp")?.status).toBe("active"); + }); }); diff --git a/src/features/jwt-decoder/jwt-decoder.service.ts b/src/features/jwt-decoder/jwt-decoder.service.ts index 9c1de93..7d2b8e4 100644 --- a/src/features/jwt-decoder/jwt-decoder.service.ts +++ b/src/features/jwt-decoder/jwt-decoder.service.ts @@ -2,13 +2,92 @@ export interface JwtDecoderInput { value: string; } +export type JwtPartName = "header" | "payload" | "signature"; +export type JwtClaimStatus = "active" | "expired" | "future" | "missing"; +export type JwtAlgorithm = + | "none" + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "ES256" + | "ES384" + | "ES512" + | "PS256" + | "PS384" + | "PS512" + | "EdDSA"; +export type JwtVerificationStatus = + | "idle" + | "verified" + | "failed" + | "unsupported" + | "error"; + +export interface JwtClaimInsight { + description: string; + name: string; + status?: JwtClaimStatus; + value: string; +} + +export interface JwtBreakdownRow { + description: string; + name: string; + status?: JwtClaimStatus; + value: string; +} + +export interface JwtSecurityWarning { + message: string; + tone: "amber" | "rose"; + title: string; +} + +export interface JwtVerificationResult { + message: string; + status: JwtVerificationStatus; +} + export interface DecodedJwt { + algorithm?: string; + claims: JwtClaimInsight[]; error?: string; header: string; + headerJson?: Record; + headerSegment: string; + isExpired: boolean; + isNotYetValid: boolean; + issuedAt?: Date; payload: string; + payloadJson?: Record; + payloadSegment: string; signature: string; + signatureBytes: number; + token: string; + type?: string; + warnings: JwtSecurityWarning[]; } +export const jwtAlgorithms: JwtAlgorithm[] = [ + "none", + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "EdDSA", +]; + export function normalizeJwtDecoderInput(input: string): string { return input.trim(); } @@ -17,40 +96,246 @@ export function hasJwtDecoderInput(input: string): boolean { return normalizeJwtDecoderInput(input).length > 0; } -export function decodeJwt(input: string): DecodedJwt { +export function decodeJwt(input: string, now = new Date()): DecodedJwt { const token = normalizeJwtDecoderInput(input); - const [header, payload, signature, ...rest] = token.split("."); + const parts = token.split("."); + const [headerSegment = "", payloadSegment = "", signature = ""] = parts; if (!token) { - return { header: "", payload: "", signature: "" }; + return createEmptyDecodedJwt(); } - if (!header || !payload || !signature || rest.length > 0) { + if (parts.length !== 3 || !headerSegment || !payloadSegment) { return { + ...createEmptyDecodedJwt(), error: "JWT must contain exactly three dot-separated sections.", - header: "", - payload: "", - signature: "", + token, }; } try { + const headerJson = parseJwtSection(headerSegment, "header"); + const payloadJson = parseJwtSection(payloadSegment, "payload"); + const algorithm = asString(headerJson.alg); + const type = asString(headerJson.typ); + const claims = getClaimInsights(payloadJson, now); + const warnings = getSecurityWarnings(headerJson, payloadJson, signature); + return { - header: JSON.stringify(JSON.parse(decodeBase64Url(header)), null, 2), - payload: JSON.stringify(JSON.parse(decodeBase64Url(payload)), null, 2), + algorithm, + claims, + header: stringifyJson(headerJson), + headerJson, + headerSegment, + isExpired: getExpirationStatus(payloadJson, now) === "expired", + isNotYetValid: getNotBeforeStatus(payloadJson, now) === "future", + issuedAt: getDateClaim(payloadJson.iat), + payload: stringifyJson(payloadJson), + payloadJson, + payloadSegment, signature, + signatureBytes: getBase64UrlByteLength(signature), + token, + type, + warnings, }; } catch (error) { return { + ...createEmptyDecodedJwt(), error: error instanceof Error ? error.message : "Unable to decode JWT.", - header: "", - payload: "", + headerSegment, + payloadSegment, signature, + token, + }; + } +} + +export async function verifyJwtSignature( + input: string, + secret: string, +): Promise { + const decoded = decodeJwt(input); + + if (!hasJwtDecoderInput(input)) { + return { message: "Paste a JWT before verifying the signature.", status: "idle" }; + } + + if (decoded.error) { + return { message: decoded.error, status: "error" }; + } + + if (!secret) { + return { + message: "Enter the shared secret used to sign this token.", + status: "idle", + }; + } + + if (!decoded.algorithm) { + return { message: "JWT header does not declare an algorithm.", status: "error" }; + } + + if (decoded.algorithm === "none") { + return { + message: "Unsigned tokens cannot be verified.", + status: "unsupported", + }; + } + + const hash = getHmacHash(decoded.algorithm); + + if (!hash) { + return { + message: `${decoded.algorithm} verification is not supported in Forge yet. Use the public/private key verifier in your auth stack for asymmetric tokens.`, + status: "unsupported", + }; + } + + try { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { hash, name: "HMAC" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`${decoded.headerSegment}.${decoded.payloadSegment}`), + ); + const expected = encodeBase64Url(new Uint8Array(signature)); + const verified = timingSafeEqual(expected, decoded.signature); + + return verified + ? { message: "Signature verified with the provided secret.", status: "verified" } + : { message: "Signature does not match the provided secret.", status: "failed" }; + } catch (error) { + return { + message: error instanceof Error ? error.message : "Unable to verify signature.", + status: "error", }; } } +export async function generateJwtExample( + algorithm: JwtAlgorithm, + secret = "forge-secret", +): Promise { + const issuedAt = Math.floor(Date.now() / 1000); + const header: Record = { + alg: algorithm, + typ: "JWT", + kid: algorithm === "none" ? undefined : `forge-${algorithm.toLowerCase()}-example`, + }; + const payload = { + sub: "user_42", + name: "Forge Developer", + admin: false, + iss: "https://auth.forge.local", + aud: ["forge-api", "forge-cli"], + iat: issuedAt, + nbf: issuedAt, + exp: issuedAt + 60 * 60 * 24, + scope: "tools:read tools:write", + roles: ["developer", "reviewer"], + meta: { + algorithm, + localOnly: true, + workspace: "orcace", + }, + }; + const headerSegment = encodeJsonBase64Url(removeUndefined(header)); + const payloadSegment = encodeJsonBase64Url(payload); + + if (algorithm === "none") { + return `${headerSegment}.${payloadSegment}.`; + } + + const hash = getHmacHash(algorithm); + + if (hash) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { hash, name: "HMAC" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + ); + + return `${headerSegment}.${payloadSegment}.${encodeBase64Url( + new Uint8Array(signature), + )}`; + } + + return `${headerSegment}.${payloadSegment}.${encodeBase64Url( + new TextEncoder().encode(`${algorithm}.signature.placeholder`), + )}`; +} + +export function getJwtBreakdownRows( + value: Record | undefined, + section: "header" | "payload", + now = new Date(), +): JwtBreakdownRow[] { + if (!value) { + return []; + } + + return Object.entries(value).map(([name, item]) => ({ + description: getClaimDescription(name, section), + name, + status: + section === "payload" && name === "exp" + ? getExpirationStatus(value, now) + : section === "payload" && name === "nbf" + ? getNotBeforeStatus(value, now) + : undefined, + value: + section === "payload" && ["exp", "nbf", "iat"].includes(name) + ? formatDateClaim(item, now) + : formatClaimValue(item), + })); +} + +function createEmptyDecodedJwt(): DecodedJwt { + return { + claims: [], + header: "", + headerSegment: "", + isExpired: false, + isNotYetValid: false, + payload: "", + payloadSegment: "", + signature: "", + signatureBytes: 0, + token: "", + warnings: [], + }; +} + +function parseJwtSection(segment: string, label: JwtPartName): Record { + const decoded = decodeBase64Url(segment); + const parsed = JSON.parse(decoded) as unknown; + + if (!isPlainObject(parsed)) { + throw new Error(`JWT ${label} must decode to a JSON object.`); + } + + return parsed; +} + function decodeBase64Url(input: string): string { + if (!/^[A-Za-z0-9_-]*$/.test(input)) { + throw new Error("JWT contains invalid Base64URL characters."); + } + const base64 = input.replaceAll("-", "+").replaceAll("_", "/"); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); const binary = atob(padded); @@ -58,3 +343,277 @@ function decodeBase64Url(input: string): string { return new TextDecoder().decode(bytes); } + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = ""; + + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +function encodeJsonBase64Url(value: unknown): string { + return encodeBase64Url(new TextEncoder().encode(JSON.stringify(value))); +} + +function getClaimInsights( + payload: Record, + now: Date, +): JwtClaimInsight[] { + return [ + { + description: "Subject", + name: "sub", + value: formatClaimValue(payload.sub), + }, + { + description: "Issuer", + name: "iss", + value: formatClaimValue(payload.iss), + }, + { + description: "Audience", + name: "aud", + value: formatClaimValue(payload.aud), + }, + { + description: "Expiration", + name: "exp", + status: getExpirationStatus(payload, now), + value: formatDateClaim(payload.exp, now), + }, + { + description: "Not before", + name: "nbf", + status: getNotBeforeStatus(payload, now), + value: formatDateClaim(payload.nbf, now), + }, + { + description: "Issued at", + name: "iat", + value: formatDateClaim(payload.iat, now), + }, + { + description: "JWT ID", + name: "jti", + value: formatClaimValue(payload.jti), + }, + ]; +} + +function getSecurityWarnings( + header: Record, + payload: Record, + signature: string, +): JwtSecurityWarning[] { + const warnings: JwtSecurityWarning[] = []; + const algorithm = asString(header.alg); + + if (!algorithm) { + warnings.push({ + message: "A JWT should declare the signing algorithm in the header.", + title: "Missing algorithm", + tone: "rose", + }); + } else if (algorithm === "none") { + warnings.push({ + message: + "This token declares alg: none. Treat it as untrusted unless your system explicitly accepts unsigned tokens.", + title: "Unsigned token", + tone: "rose", + }); + } + + if (!signature) { + warnings.push({ + message: "The third JWT section is empty, so there is no signature to verify.", + title: "Missing signature", + tone: "rose", + }); + } + + if (payload.exp === undefined) { + warnings.push({ + message: "Tokens without exp can stay valid forever if the server accepts them.", + title: "No expiration claim", + tone: "amber", + }); + } + + if (payload.iss === undefined) { + warnings.push({ + message: + "Issuer validation is usually required for multi-tenant and OAuth/OIDC flows.", + title: "No issuer claim", + tone: "amber", + }); + } + + if (payload.aud === undefined) { + warnings.push({ + message: + "Audience validation helps prevent tokens issued for one API from being replayed against another.", + title: "No audience claim", + tone: "amber", + }); + } + + return warnings; +} + +function getExpirationStatus( + payload: Record, + now: Date, +): JwtClaimStatus { + const expiration = getDateClaim(payload.exp); + + if (!expiration) { + return "missing"; + } + + return expiration.getTime() <= now.getTime() ? "expired" : "active"; +} + +function getNotBeforeStatus(payload: Record, now: Date): JwtClaimStatus { + const notBefore = getDateClaim(payload.nbf); + + if (!notBefore) { + return "missing"; + } + + return notBefore.getTime() > now.getTime() ? "future" : "active"; +} + +function getDateClaim(value: unknown): Date | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + + return new Date(value * 1000); +} + +function formatDateClaim(value: unknown, now: Date): string { + const date = getDateClaim(value); + + if (!date) { + return "Missing"; + } + + const deltaSeconds = Math.round((date.getTime() - now.getTime()) / 1000); + const relative = + deltaSeconds >= 0 + ? `in ${formatDuration(deltaSeconds)}` + : `${formatDuration(Math.abs(deltaSeconds))} ago`; + + return `${date.toISOString()} (${relative})`; +} + +function formatDuration(totalSeconds: number): string { + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) { + return `${days}d ${hours}h`; + } + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + + return `${seconds}s`; +} + +function formatClaimValue(value: unknown): string { + if (value === undefined) { + return "Missing"; + } + + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + + return JSON.stringify(value); +} + +function getClaimDescription(name: string, section: "header" | "payload"): string { + const descriptions: Record = { + alg: "Algorithm used to sign or verify the JWT.", + aud: "Recipients that the JWT is intended for.", + exp: "Expiration time as NumericDate seconds.", + iat: "Time at which the JWT was issued.", + iss: "Principal that issued the JWT.", + jti: "Unique identifier for replay detection.", + kid: "Key identifier used to select the verification key.", + name: "Human-readable display name carried by this token.", + nbf: "Time before which the JWT must not be accepted.", + scope: "Space-delimited permissions requested or granted.", + sub: "Principal that is the subject of the JWT.", + typ: "Media type of this token, usually JWT.", + }; + + return ( + descriptions[name] ?? + (section === "header" + ? "Custom JOSE header parameter." + : "Custom private or public claim.") + ); +} + +function getBase64UrlByteLength(input: string): number { + if (!input) { + return 0; + } + + const padding = input.length % 4 === 0 ? 0 : 4 - (input.length % 4); + const paddedLength = input.length + padding; + + return Math.floor((paddedLength * 3) / 4) - padding; +} + +function getHmacHash(algorithm: string): string | undefined { + return { + HS256: "SHA-256", + HS384: "SHA-384", + HS512: "SHA-512", + }[algorithm]; +} + +function timingSafeEqual(left: string, right: string): boolean { + if (left.length !== right.length) { + return false; + } + + let difference = 0; + + for (let index = 0; index < left.length; index += 1) { + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + + return difference === 0; +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function removeUndefined(value: Record): Record { + return Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== undefined), + ); +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} From 09d27d58ceff5f121747e3b7665ee91b99a93e55 Mon Sep 17 00:00:00 2001 From: Cu Thanh Cam Date: Sun, 5 Jul 2026 16:51:55 +0700 Subject: [PATCH 2/2] Polish JWT decoder workspace --- src/features/jwt-decoder/JwtDecoderPage.tsx | 870 ++++++++++++++++++-- 1 file changed, 812 insertions(+), 58 deletions(-) diff --git a/src/features/jwt-decoder/JwtDecoderPage.tsx b/src/features/jwt-decoder/JwtDecoderPage.tsx index c701e92..3755267 100644 --- a/src/features/jwt-decoder/JwtDecoderPage.tsx +++ b/src/features/jwt-decoder/JwtDecoderPage.tsx @@ -1,65 +1,198 @@ -import type { JSX } from "react"; -import { useMemo, useState } from "react"; -import { Copy, RotateCcw, ShieldCheck } from "lucide-react"; +import type { JSX, ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { + Check, + CheckCircle2, + ChevronDown, + Clock3, + Copy, + Download, + FileJson2, + Fingerprint, + KeyRound, + RotateCcw, + ShieldAlert, + ShieldCheck, + Signature, + Sparkles, + TriangleAlert, + WrapText, + XCircle, +} from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Tooltip } from "@/shared/ui/tooltip"; +import { PaneHeader, ToolSurface, ToolToolbar } from "@/shared/components/ToolSurface"; import { - PaneHeader, - ToolOutput, - ToolSurface, - ToolTextarea, - ToolToolbar, - ToolTitle, -} from "@/shared/components/ToolSurface"; -import { decodeJwt } from "./jwt-decoder.service"; + decodeJwt, + generateJwtExample, + getJwtBreakdownRows, + jwtAlgorithms, + verifyJwtSignature, + type DecodedJwt, + type JwtAlgorithm, + type JwtBreakdownRow, + type JwtClaimInsight, + type JwtClaimStatus, + type JwtSecurityWarning, + type JwtVerificationResult, +} from "./jwt-decoder.service"; + +type DecoderView = "decoded" | "claims" | "verify"; +type DecodedSectionView = "breakdown" | "json"; + +const sampleToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvcmdlLWRldi1rZXkifQ.eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkZvcmdlIERldmVsb3BlciIsImFkbWluIjpmYWxzZSwiaXNzIjoiaHR0cHM6Ly9hdXRoLmZvcmdlLmxvY2FsIiwiYXVkIjpbImZvcmdlLWFwaSIsImZvcmdlLWNsaSJdLCJpYXQiOjE3MTk4MDAwMDAsIm5iZiI6MTcxOTgwMDAwMCwiZXhwIjoyNTMyNDAwMDAwLCJzY29wZSI6InRvb2xzOnJlYWQgdG9vbHM6d3JpdGUiLCJyb2xlcyI6WyJkZXZlbG9wZXIiLCJyZXZpZXdlciJdLCJtZXRhIjp7IndvcmtzcGFjZSI6Im9yY2FjZSIsImxvY2FsT25seSI6dHJ1ZX19.7LZ-tyRUyyrqelMiTMJxfvxwKHUXn-ViflCiJLCL-4A"; export function JwtDecoderPage(): JSX.Element { - const [token, setToken] = useState(""); + const [token, setToken] = useState(sampleToken); + const [view, setView] = useState("decoded"); + const [secret, setSecret] = useState("forge-secret"); + const [lineWrap, setLineWrap] = useState(true); + const [exampleAlgorithm, setExampleAlgorithm] = useState("HS256"); + const [verification, setVerification] = useState({ + message: "Enter a shared secret to verify HMAC signatures.", + status: "idle", + }); const decoded = useMemo(() => decodeJwt(token), [token]); - async function copyPayload(): Promise { - await navigator.clipboard.writeText(decoded.payload); + useEffect(() => { + let cancelled = false; + + void verifyJwtSignature(token, secret).then((result) => { + if (!cancelled) { + setVerification(result); + } + }); + + return () => { + cancelled = true; + }; + }, [secret, token]); + + async function copyToken(): Promise { + await navigator.clipboard.writeText(token); + } + + async function copyDecoded(): Promise { + await navigator.clipboard.writeText( + JSON.stringify( + { + header: decoded.headerJson, + payload: decoded.payloadJson, + signature: decoded.signature, + }, + null, + 2, + ), + ); + } + + function downloadDecoded(): void { + const blob = new Blob( + [ + JSON.stringify( + { + header: decoded.headerJson, + payload: decoded.payloadJson, + signature: decoded.signature, + }, + null, + 2, + ), + ], + { type: "application/json;charset=utf-8" }, + ); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = "forge-jwt-decoded.json"; + anchor.click(); + URL.revokeObjectURL(url); + } + + async function generateExample(algorithm: JwtAlgorithm): Promise { + setExampleAlgorithm(algorithm); + setToken(await generateJwtExample(algorithm, secret)); + setView("decoded"); } return ( - -

-

- +
+
+ } + title="Encoded token" + tone="blue" /> + -
- -
-
-                {decoded.header || "Header"}
-              
-
-                {decoded.payload || "Payload"}
-              
-

- {decoded.signature || "Signature"} -

-
-
+ +
+ } + title={} + tone={decoded.error ? "rose" : "emerald"} + /> + {decoded.error ? ( + + ) : view === "claims" ? ( + + ) : view === "verify" ? ( + + ) : ( + + )}
); } + +interface ExampleControlProps { + onGenerate: (algorithm: JwtAlgorithm) => void; + value: JwtAlgorithm; +} + +function ExampleControl({ onGenerate, value }: ExampleControlProps): JSX.Element { + return ( + + + + + + + {jwtAlgorithms.map((algorithm) => ( + onGenerate(algorithm)} + > + {algorithm} + {value === algorithm ? ( + + ))} + + + + ); +} + +interface ViewButtonProps { + active: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +} + +function ViewButton({ active, icon, label, onClick }: ViewButtonProps): JSX.Element { + return ( + + ); +} + +function StatusPill({ + decoded, + verification, +}: { + decoded: DecodedJwt; + verification: JwtVerificationResult; +}): JSX.Element { + if (decoded.error) { + return ( + + + ); + } + + if (!decoded.payload) { + return ( + + + ); + } + + if (verification.status === "verified") { + return ( + + + ); + } + + return ( + + + ); +} + +function TokenInput({ + lineWrap, + onChange, + value, +}: { + lineWrap: boolean; + onChange: (value: string) => void; + value: string; +}): JSX.Element { + const segments = value.split("."); + + return ( +
+ +