First implementation of Plimi
This commit is contained in:
30
src/tools/jwt-decoder/index.ts
Normal file
30
src/tools/jwt-decoder/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||
import { runJwtDecoder } from "./run";
|
||||
|
||||
export const jwtDecoderPlugin: PlimiPlugin = {
|
||||
manifest: {
|
||||
id: "jwt-decoder",
|
||||
name: "JWT Decoder",
|
||||
description: "Decode and inspect JSON Web Tokens (JWT) locally in your browser.",
|
||||
category: "developer",
|
||||
version: "1.0.0",
|
||||
tags: ["jwt", "token", "decoder", "auth", "json"],
|
||||
input: {
|
||||
type: "text",
|
||||
label: "JWT Token",
|
||||
placeholder: "Paste your JWT token here (encoded header.payload.signature)...",
|
||||
multiline: true,
|
||||
rows: 6,
|
||||
},
|
||||
output: { type: "json" },
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE4MTYyMzkwMjJ9.signature-placeholder",
|
||||
offlineReady: true,
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
cancelable: false,
|
||||
worker: false,
|
||||
},
|
||||
|
||||
run: runJwtDecoder,
|
||||
};
|
||||
81
src/tools/jwt-decoder/run.test.ts
Normal file
81
src/tools/jwt-decoder/run.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runJwtDecoder } from "./run";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
|
||||
describe("JWT Decoder Plugin", () => {
|
||||
const mockContext: ToolContext = {
|
||||
signal: new AbortController().signal,
|
||||
reportProgress: vi.fn(),
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
|
||||
// {"sub":"123","name":"John Doe","iat":1516239022} -> eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9
|
||||
const validNoExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.sig";
|
||||
|
||||
// {"sub":"123","exp":32503680000} (Year 3000) -> eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ
|
||||
const futureExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ.sig";
|
||||
|
||||
// {"sub":"123","exp":946684800} (Year 2000) -> eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0
|
||||
const expiredExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0.sig";
|
||||
|
||||
it("should decode a valid JWT without exp claim", async () => {
|
||||
const result = await runJwtDecoder({ text: validNoExp }, {}, mockContext);
|
||||
expect(result.type).toBe("json");
|
||||
if (result.type === "json") {
|
||||
const data = result.value as any;
|
||||
expect(data.status).toBe("valid");
|
||||
expect(data.primary).toContain("Valid (No Expiration claim)");
|
||||
expect(data.header.alg).toBe("HS256");
|
||||
expect(data.payload.sub).toBe("123");
|
||||
expect(data.issuedAt).toBe("2018-01-18T01:30:22.000Z");
|
||||
expect(data.expiresAt).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("should decode a valid JWT with future exp claim", async () => {
|
||||
const result = await runJwtDecoder({ text: futureExp }, {}, mockContext);
|
||||
expect(result.type).toBe("json");
|
||||
if (result.type === "json") {
|
||||
const data = result.value as any;
|
||||
expect(data.status).toBe("valid");
|
||||
expect(data.primary).toContain("Valid (Expires:");
|
||||
expect(data.expiresAt).toBe("3000-01-01T00:00:00.000Z");
|
||||
}
|
||||
});
|
||||
|
||||
it("should decode an expired JWT with past exp claim", async () => {
|
||||
const result = await runJwtDecoder({ text: expiredExp }, {}, mockContext);
|
||||
expect(result.type).toBe("json");
|
||||
if (result.type === "json") {
|
||||
const data = result.value as any;
|
||||
expect(data.status).toBe("expired");
|
||||
expect(data.primary).toContain("Expired (at");
|
||||
expect(data.expiresAt).toBe("2000-01-01T00:00:00.000Z");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error for empty token", async () => {
|
||||
await expect(
|
||||
runJwtDecoder({ text: "" }, {}, mockContext)
|
||||
).rejects.toThrow("Please enter a JWT token to decode.");
|
||||
});
|
||||
|
||||
it("should throw error for malformed token format", async () => {
|
||||
await expect(
|
||||
runJwtDecoder({ text: "one.two" }, {}, mockContext)
|
||||
).rejects.toThrow("A JWT must consist of three parts");
|
||||
});
|
||||
|
||||
it("should throw error for invalid JSON payload", async () => {
|
||||
// Header ok, payload invalid base64/json: "invalid" -> aW52YWxpZA==
|
||||
const invalidJson = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aW52YWxpZA.sig";
|
||||
await expect(
|
||||
runJwtDecoder({ text: invalidJson }, {}, mockContext)
|
||||
).rejects.toThrow("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
|
||||
});
|
||||
});
|
||||
92
src/tools/jwt-decoder/run.ts
Normal file
92
src/tools/jwt-decoder/run.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ToolInput } from "../../core/io/input-types";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const paddingNeeded = (4 - (base64.length % 4)) % 4;
|
||||
const padded = base64 + "=".repeat(paddingNeeded);
|
||||
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export async function runJwtDecoder(
|
||||
input: ToolInput,
|
||||
_options: unknown,
|
||||
context: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const token = (input.text ?? "").trim();
|
||||
if (!token) {
|
||||
throw new Error("Please enter a JWT token to decode.");
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 20, message: "Parsing token parts..." });
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT token format. A JWT must consist of three parts (header, payload, signature) separated by dots.");
|
||||
}
|
||||
|
||||
const [headerB64, payloadB64, signatureHex] = parts;
|
||||
|
||||
context.reportProgress({ percentage: 50, message: "Decoding JSON content..." });
|
||||
|
||||
let header: Record<string, unknown>;
|
||||
let payload: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const decodedHeader = base64urlDecode(headerB64);
|
||||
header = JSON.parse(decodedHeader);
|
||||
} catch (err) {
|
||||
throw new Error("Failed to decode JWT Header: invalid Base64URL encoding or malformed JSON content.");
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedPayload = base64urlDecode(payloadB64);
|
||||
payload = JSON.parse(decodedPayload);
|
||||
} catch (err) {
|
||||
throw new Error("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 85, message: "Checking claims..." });
|
||||
|
||||
let expiresAt: string | null = null;
|
||||
let issuedAt: string | null = null;
|
||||
let status: "valid" | "expired" = "valid";
|
||||
let primary = "Valid (No Expiration claim)";
|
||||
|
||||
if (typeof payload.exp === "number") {
|
||||
const expTime = payload.exp * 1000;
|
||||
expiresAt = new Date(expTime).toISOString();
|
||||
if (Date.now() > expTime) {
|
||||
status = "expired";
|
||||
primary = `Expired (at ${expiresAt})`;
|
||||
} else {
|
||||
primary = `Valid (Expires: ${expiresAt})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload.iat === "number") {
|
||||
issuedAt = new Date(payload.iat * 1000).toISOString();
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
|
||||
return {
|
||||
type: "json",
|
||||
value: {
|
||||
primary,
|
||||
status,
|
||||
expiresAt,
|
||||
issuedAt,
|
||||
header,
|
||||
payload,
|
||||
signature: signatureHex || "(empty)",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user