First implementation of Plimi

This commit is contained in:
achraf
2026-06-02 19:32:51 +02:00
commit be635b1828
136 changed files with 13663 additions and 0 deletions

View 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,
};

View 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.");
});
});

View 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)",
},
};
}