Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -25,7 +25,7 @@ npm install uncrypto
yarn add uncrypto

# pnpm
pnpm install uncrypto
pnpm add uncrypto
```

Import:
Expand All @@ -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
Expand Down
44 changes: 22 additions & 22 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand All @@ -57,4 +57,4 @@
"vitest": "^0.34.5"
},
"packageManager": "pnpm@8.8.0"
}
}
File renamed without changes.
File renamed without changes.
4 changes: 4 additions & 0 deletions src/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./jwt/sign";
export * from "./jwt/verify";
export * from "./jwt/decode";
export * from "./jwt/types";
59 changes: 59 additions & 0 deletions src/jwt/_utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 24 additions & 0 deletions src/jwt/decode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { decodeFromBase64Url } from "./_utils";
import type { JWTRegisteredClaims } from "./types";

export function decodeJWT<
T extends Record<string, unknown> = Record<string, unknown>
>(token: string) {
try {
return _decodeJWT<T>(token);
} catch {}
}

// --- Internal ---

function _decodeJWT<
T extends Record<string, unknown> = Record<string, unknown>
>(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;
}
57 changes: 57 additions & 0 deletions src/jwt/sign.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
HMAC: "HS",
RSA: "RS",
ECDSA: "ES",
};

export async function signJWT<
T extends Record<string, unknown> = Record<string, unknown>
>(options: SignJWTOptions<T>) {
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}`;
}
80 changes: 80 additions & 0 deletions src/jwt/types.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Record<string, unknown>> {
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;
}
63 changes: 63 additions & 0 deletions src/jwt/verify.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
1 change: 0 additions & 1 deletion src/utils.ts

This file was deleted.