diff --git a/README.md b/README.md index 62a5546..1558ab1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Github Actions][github-actions-src]][github-actions-href] [![Codecov][codecov-src]][codecov-href] -This library provides a single api to use [web-crypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) and [Subtle Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) in both Node.js using [Crypto Module](https://nodejs.org/api/crypto.html#crypto) and Web targets using [Web Crypto API](https://nodejs.org/api/crypto.html#crypto) using [Conditional Exports](https://nodejs.org/api/packages.html#conditional-exports). +This library provides a single API to use [web-crypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) and [Subtle Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) in both Node.js using [Crypto Module](https://nodejs.org/api/crypto.html#crypto) and Web targets using [Web Crypto API](https://nodejs.org/api/crypto.html#crypto) using [Conditional Exports](https://nodejs.org/api/packages.html#conditional-exports). **Requirements:** @@ -25,7 +25,7 @@ npm install uncrypto yarn add uncrypto # pnpm -pnpm install uncrypto +pnpm add uncrypto ``` Import: @@ -38,6 +38,16 @@ import { subtle, randomUUID, getRandomValues } from "uncrypto"; const { subtle, randomUUID, getRandomValues } = require("uncrypto"); ``` +To use JWT utilities, import from `uncrypto/jwt`: + +```js +// ESM +import { decodeJWT, signJWT, verifyJWT } from "uncrypto/jwt"; + +// CommonJS +const { decodeJWT, signJWT, verifyJWT } = require("uncrypto/jwt"); +``` + ## Development - Clone this repository diff --git a/package.json b/package.json index 1ee1a65..4d1594c 100644 --- a/package.json +++ b/package.json @@ -8,31 +8,31 @@ "type": "module", "exports": { ".": { - "browser": "./dist/crypto.web.mjs", - "bun": "./dist/crypto.web.mjs", - "deno": "./dist/crypto.web.mjs", - "edge-light": "./dist/crypto.web.mjs", - "edge-routine": "./dist/crypto.web.mjs", - "lagon": "./dist/crypto.web.mjs", - "netlify": "./dist/crypto.web.mjs", - "react-native": "./dist/crypto.web.mjs", - "wintercg": "./dist/crypto.web.mjs", - "worker": "./dist/crypto.web.mjs", - "workerd": "./dist/crypto.web.mjs", + "browser": "./dist/crypto-web.mjs", + "bun": "./dist/crypto-web.mjs", + "deno": "./dist/crypto-web.mjs", + "edge-light": "./dist/crypto-web.mjs", + "edge-routine": "./dist/crypto-web.mjs", + "lagon": "./dist/crypto-web.mjs", + "netlify": "./dist/crypto-web.mjs", + "react-native": "./dist/crypto-web.mjs", + "wintercg": "./dist/crypto-web.mjs", + "worker": "./dist/crypto-web.mjs", + "workerd": "./dist/crypto-web.mjs", "node": { - "require": "./dist/crypto.node.cjs", - "import": "./dist/crypto.node.mjs", - "types": "./dist/crypto.node.d.ts" + "types": "./dist/crypto-node.d.ts", + "import": "./dist/crypto-node.mjs", + "require": "./dist/crypto-node.cjs" }, - "require": "./dist/crypto.web.cjs", - "import": "./dist/crypto.web.mjs", - "types": "./dist/crypto.web.d.ts" + "require": "./dist/crypto-web.cjs", + "import": "./dist/crypto-web.mjs", + "types": "./dist/crypto-web.d.ts" } }, - "main": "./dist/crypto.node.cjs", - "module": "./dist/crypto.web.mjs", - "browser": "./dist/crypto.web.mjs", - "types": "./dist/crypto.web.d.ts", + "main": "./dist/crypto-node.cjs", + "module": "./dist/crypto-web.mjs", + "browser": "./dist/crypto-web.mjs", + "types": "./dist/crypto-web.d.ts", "files": [ "dist" ], @@ -57,4 +57,4 @@ "vitest": "^0.34.5" }, "packageManager": "pnpm@8.8.0" -} \ No newline at end of file +} diff --git a/src/crypto.node.ts b/src/crypto-node.ts similarity index 100% rename from src/crypto.node.ts rename to src/crypto-node.ts diff --git a/src/crypto.web.ts b/src/crypto-web.ts similarity index 100% rename from src/crypto.web.ts rename to src/crypto-web.ts diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 0000000..ccb1cd0 --- /dev/null +++ b/src/jwt.ts @@ -0,0 +1,4 @@ +export * from "./jwt/sign"; +export * from "./jwt/verify"; +export * from "./jwt/decode"; +export * from "./jwt/types"; diff --git a/src/jwt/_utils.ts b/src/jwt/_utils.ts new file mode 100644 index 0000000..2ba52d0 --- /dev/null +++ b/src/jwt/_utils.ts @@ -0,0 +1,59 @@ +// @ts-expect-error: use export conditions +import { subtle } from "uncrypto"; + +export const DEFAULT_SIGNATURE_METHOD = "HMAC"; +export const DEFAULT_HASH_METHODS = "SHA-256"; +export const textEncoder = new TextEncoder(); + +export async function createSignature( + key: CryptoKey, + algorithm: string, + data: string +) { + const encoded = textEncoder.encode(data); + const signature = await subtle.sign(algorithm, key, encoded); + return encodeToBase64Url(signature); +} + +export async function importKey({ + secret, + usage = "sign", + name = DEFAULT_SIGNATURE_METHOD, + hash = DEFAULT_HASH_METHODS, +}: { + secret: string; + usage?: "sign" | "verify"; + name: string; + hash: string; +}) { + const encodedKey = textEncoder.encode(secret); + return await subtle.importKey("raw", encodedKey, { name, hash }, false, [ + usage, + ]); +} + +export function encodeToBase64Url(input: unknown) { + let data: Uint8Array; + if (typeof input === "string") { + data = textEncoder.encode(input); + } else if (input instanceof ArrayBuffer) { + data = new Uint8Array(input); + } else { + data = textEncoder.encode(JSON.stringify(input)); + } + + const base64 = btoa(String.fromCodePoint(...data)); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function decodeFromBase64Url(base64Url: string) { + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + return atob(base64); +} + +export function decodeFromBase64UrlToBuffer(base64Url: string) { + const bytes = decodeFromBase64Url(base64Url); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const buffer = Uint8Array.from(bytes, (c) => c.codePointAt(0)!); + return buffer.buffer; +} diff --git a/src/jwt/decode.ts b/src/jwt/decode.ts new file mode 100644 index 0000000..039bfee --- /dev/null +++ b/src/jwt/decode.ts @@ -0,0 +1,24 @@ +import { decodeFromBase64Url } from "./_utils"; +import type { JWTRegisteredClaims } from "./types"; + +export function decodeJWT< + T extends Record = Record +>(token: string) { + try { + return _decodeJWT(token); + } catch {} +} + +// --- Internal --- + +function _decodeJWT< + T extends Record = Record +>(token: string) { + const parts = String(token).split("."); + if (parts.length !== 3) { + throw new Error("Invalid token format"); + } + + const [, payload] = parts; + return JSON.parse(decodeFromBase64Url(payload)) as JWTRegisteredClaims & T; +} diff --git a/src/jwt/sign.ts b/src/jwt/sign.ts new file mode 100644 index 0000000..574c62f --- /dev/null +++ b/src/jwt/sign.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_HASH_METHODS, + DEFAULT_SIGNATURE_METHOD, + createSignature, + encodeToBase64Url, + importKey, +} from "./_utils"; +import type { JWTRegisteredClaims, SignJWTOptions } from "./types"; + +const headerAlgMap: Record = { + HMAC: "HS", + RSA: "RS", + ECDSA: "ES", +}; + +export async function signJWT< + T extends Record = Record +>(options: SignJWTOptions) { + const { + payload = {} as T, + secret, + issuer, + audience, + expires = 30, + signatureMethod = DEFAULT_SIGNATURE_METHOD, + hashMethod = DEFAULT_HASH_METHODS, + } = { ...options }; + + const key = await importKey({ + secret, + name: signatureMethod, + hash: hashMethod, + }); + + const header = { + typ: "JWT", + alg: `${headerAlgMap[signatureMethod]}${hashMethod.split("-")[1]}`, + }; + const iat = Math.floor(Date.now() / 1000); + const exp = iat + expires * 24 * 60 * 60; + const jwtPayload: JWTRegisteredClaims & T = { + ...payload, + iss: issuer, + aud: audience, + iat, + exp, + }; + + const signature = await createSignature( + key, + signatureMethod, + `${encodeToBase64Url(header)}.${encodeToBase64Url(jwtPayload)}` + ); + return `${encodeToBase64Url(header)}.${encodeToBase64Url( + jwtPayload + )}.${signature}`; +} diff --git a/src/jwt/types.ts b/src/jwt/types.ts new file mode 100644 index 0000000..2bb1dbc --- /dev/null +++ b/src/jwt/types.ts @@ -0,0 +1,80 @@ +/** Recognized JWT Claims Set members */ +export interface JWTRegisteredClaims { + /** + * JWT Issuer + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1 RFC7519#section-4.1.1} + */ + iss?: string; + + /** + * JWT Subject + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2 RFC7519#section-4.1.2} + */ + sub?: string; + + /** + * JWT Audience + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3 RFC7519#section-4.1.3} + */ + aud?: string | string[]; + + /** + * JWT ID + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.7 RFC7519#section-4.1.7} + */ + jti?: string; + + /** + * JWT Not Before + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5 RFC7519#section-4.1.5} + */ + nbf?: number; + + /** + * JWT Expiration Time + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4 RFC7519#section-4.1.4} + */ + exp?: number; + + /** + * JWT Issued At + * + * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 RFC7519#section-4.1.6} + */ + iat?: number; +} + +export interface SignJWTOptions> { + payload?: T; + secret: string; + issuer: string; + audience: string; + /** + * Expiration time in days + * @default 30 + */ + expires?: number; + /** @default 'HMAC' */ + signatureMethod?: string; + /** @default 'SHA-256' */ + hashMethod?: string; +} + +export interface VerifyJWTOptions { + token: string; + secret: string; + issuer: string; + audience: string; + /** @default 60 */ + leeway?: number; + /** @default 'HMAC' */ + signatureMethod?: string; + /** @default 'SHA-256' */ + hashMethod?: string; +} diff --git a/src/jwt/verify.ts b/src/jwt/verify.ts new file mode 100644 index 0000000..7f9361a --- /dev/null +++ b/src/jwt/verify.ts @@ -0,0 +1,63 @@ +import { + DEFAULT_HASH_METHODS, + DEFAULT_SIGNATURE_METHOD, + decodeFromBase64Url, + decodeFromBase64UrlToBuffer, + importKey, + textEncoder, +} from "./_utils"; +import type { JWTRegisteredClaims, VerifyJWTOptions } from "./types"; +// @ts-expect-error: use export conditions +import { subtle } from "uncrypto"; + +export async function verifyJWT(options: VerifyJWTOptions) { + const { + token, + secret, + issuer, + audience, + leeway = 60, + signatureMethod = DEFAULT_SIGNATURE_METHOD, + hashMethod = DEFAULT_HASH_METHODS, + } = { ...options }; + + const [header, payload, signature] = String(token).split("."); + const key = await importKey({ + secret, + usage: "verify", + name: signatureMethod, + hash: hashMethod, + }); + + const isValid = await subtle.verify( + signatureMethod, + key, + decodeFromBase64UrlToBuffer(signature), + textEncoder.encode(`${header}.${payload}`) + ); + + if (!isValid) { + throw new Error("Invalid JWT signature"); + } + + let decodedPayload: JWTRegisteredClaims; + + try { + decodedPayload = JSON.parse(decodeFromBase64Url(payload)); + } catch { + throw new Error("Invalid JWT payload"); + } + + const now = Math.floor(Date.now() / 1000); + + if ( + issuer !== decodedPayload.iss || + (Array.isArray(decodedPayload.aud) + ? !decodedPayload.aud.includes(audience) + : audience !== decodedPayload.aud) || + (decodedPayload.exp && now > decodedPayload.exp + leeway) || + (decodedPayload.nbf && now < decodedPayload.nbf - leeway) + ) { + throw new Error("Invalid JWT claims"); + } +} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 1deb5e8..0000000 --- a/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -import crypto from "uncrypto";