import type { Middleware } from "../../core/types.ts";
export type JwtOptions = {
secret: string;
};
function base64urlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function base64urlDecode(s: string): Uint8Array {
let base64 = s.replace(/-/g, "+").replace(/_/g, "/");
const pad = (4 - (base64.length % 4)) % 4;
if (pad) base64 += "=".repeat(pad);
const binString = atob(base64);
const data = new Uint8Array(binString.length);
for (let i = 0; i < binString.length; i++) {
data[i] = binString.charCodeAt(i);
}
return data;
}
async function sign(data: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
encoder.encode(data),
);
return base64urlEncode(new Uint8Array(signature));
}
export async function createToken(
payload: Record<string, unknown>,
secret: string,
): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const headerPart = base64urlEncode(
new TextEncoder().encode(JSON.stringify(header)),
);
const payloadPart = base64urlEncode(
new TextEncoder().encode(JSON.stringify(payload)),
);
const data = `${headerPart}.${payloadPart}`;
const signature = await sign(data, secret);
return `${data}.${signature}`;
}
export async function verifyToken<T = Record<string, unknown>>(
token: string,
secret: string,
): Promise<T | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerPart, payloadPart, signaturePart] = parts;
const data = `${headerPart}.${payloadPart}`;
const expectedSignature = await sign(data, secret);
if (signaturePart !== expectedSignature) return null;
try {
const payloadJson = new TextDecoder().decode(base64urlDecode(payloadPart));
const payload = JSON.parse(payloadJson);
if (payload.exp && typeof payload.exp === "number") {
if (Date.now() >= payload.exp * 1000) {
return null;
}
}
return payload as T;
} catch (_e) {
return null;
}
}
export function jwt(options: JwtOptions): Middleware {
return async (req, ctx, next) => {
let token: string | null = null;
const authHeader = req.headers.get("authorization");
if (authHeader && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
} else {
const cookieHeader = req.headers.get("cookie");
if (cookieHeader) {
const m = cookieHeader.match(/(?:^|;\s*)token=([^;]+)/);
if (m) token = decodeURIComponent(m[1]);
}
}
if (!token) {
return new Response("Unauthorized", { status: 401 });
}
const payload = await verifyToken(token, options.secret);
if (!payload) {
return new Response("Unauthorized", { status: 401 });
}
if (!ctx.state) ctx.state = {};
ctx.state.user = payload;
return next();
};
}