shelving
Version:
Toolkit for using data in JavaScript.
156 lines (155 loc) • 7.48 kB
JavaScript
import { UnauthorizedError } from "../error/RequestError.js";
import { ValueError } from "../error/ValueError.js";
import { decodeBase64URLBytes, decodeBase64URLString, encodeBase64URL } from "./base64.js";
import { getBytes, requireBytes } from "./bytes.js";
import { DAY } from "./constants.js";
// Constants.
const HASH = "SHA-512";
const ALGORITHM = { name: "HMAC", hash: HASH };
const HEADER = { alg: "HS512", typ: "JWT" };
const EXPIRY_MS = DAY * 10;
const SKEW_MS = 60; // Allow 1 minute clock skew.
const SECRET_BYTES = 64; // Minimum 64 bytes / 512 bits
function _getKey(caller, secret, ...usages) {
const bytes = getBytes(secret);
if (!bytes || bytes.length < SECRET_BYTES)
throw new ValueError(`JWT secret must be byte sequence with mininum ${SECRET_BYTES} bytes`, {
received: secret,
caller,
});
return crypto.subtle.importKey("raw", requireBytes(secret), ALGORITHM, false, usages);
}
/**
* Encode a JWT and return the string token.
* - Currently only supports HMAC SHA-512 signing.
*
* @throws ValueError If the input parameters, e.g. `secret` or `issuer`, are invalid.
*/
export async function encodeToken(claims, secret) {
// Encode header.
const header = encodeBase64URL(JSON.stringify(HEADER));
// Encode payload.
const now = Math.floor(Date.now() / 1000);
const exp = Math.floor(now + EXPIRY_MS / 1000);
const payload = encodeBase64URL(JSON.stringify({ nbf: now, iat: now, exp, ...claims }));
// Create signature.
const key = await _getKey(encodeToken, secret, "sign");
const signature = encodeBase64URL(await crypto.subtle.sign("HMAC", key, requireBytes(`${header}.${payload}`)));
// Combine token.
return `${header}.${payload}.${signature}`;
}
/**
* Split a JSON Web Token into its header, payload, and signature, and decode and parse the JSON.
*/
export function splitToken(token, caller = splitToken) {
// Split token.
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature)
throw new UnauthorizedError("JWT token must have header, payload, and signature", { received: token, caller });
// Decode signature.
let signatureBytes;
try {
signatureBytes = decodeBase64URLBytes(signature);
}
catch (cause) {
throw new UnauthorizedError("JWT token signature must be Base64URL encoded", { received: signature, cause, caller });
}
// Decode header.
let headerData;
try {
headerData = JSON.parse(decodeBase64URLString(header));
}
catch (cause) {
throw new UnauthorizedError("JWT token header must be Base64URL encoded JSON", { received: header, cause, caller });
}
// Decode payload.
let payloadData;
try {
payloadData = JSON.parse(decodeBase64URLString(payload));
}
catch (cause) {
throw new UnauthorizedError("JWT token payload must be Base64URL encoded JSON", { received: payload, cause, caller });
}
return { header, payload, signature, headerData, payloadData, signatureBytes };
}
/**
* Decode a JWT, verify it, and return the full payload data.
* - Currently only supports HMAC SHA-512 signing.
*
* @throws ValueError If the input parameters, e.g. `secret` or `issuer`, are invalid.
* @throws UnauthorizedError If the token is invalid or malformed.
* @throws UnauthorizedError If the token signature is incorrect, token is expired or not issued yet.
*/
export async function verifyToken(token, secret, caller = verifyToken) {
const { header, payload, signature, headerData, payloadData } = splitToken(token, caller);
// Validate header.
if (headerData.typ !== HEADER.typ)
throw new UnauthorizedError(`JWT header type must be \"${HEADER.typ}\"`, { received: headerData.typ, caller });
if (headerData.alg !== HEADER.alg)
throw new UnauthorizedError(`JWT header algorithm must be \"${HEADER.alg}\"`, { received: headerData.alg, caller });
// Validate signature.
const key = await _getKey(verifyToken, secret, "verify");
const isValid = await crypto.subtle.verify("HMAC", key, decodeBase64URLBytes(signature), requireBytes(`${header}.${payload}`));
if (!isValid)
throw new UnauthorizedError("JWT signature does not match", { received: token, caller });
// Validate payload.
const { nbf, iat, exp } = payloadData;
const now = Math.floor(Date.now() / 1000);
if (typeof nbf === "number" && nbf < now - SKEW_MS)
throw new UnauthorizedError("JWT cannot be used yet", { received: payloadData, expected: now, caller });
if (typeof iat === "number" && iat > now + SKEW_MS)
throw new UnauthorizedError("JWT not issued yet", { received: payloadData, expected: now, caller });
if (typeof exp === "number" && exp < now - SKEW_MS)
throw new UnauthorizedError("JWT has expired", { received: payloadData, expected: now, caller });
return payloadData;
}
/**
* Set the `Authorization: Bearer {token}` on a `Request` object (by reference).
*
* @param request The `Request` object to set the token on.
* @returns The same `Request` object that was passed in.
*/
export function setRequestToken(request, token) {
request.headers.set("Authorization", `Bearer ${token}`);
return request;
}
/**
* Extract the `Authorization: Bearer {token}` from a `Request` object, or return `undefined` if not set.
*
* @param request The `Request` object possibly containing an `Authorization: Bearer {token}` header to extract the token from.
* @returns The string token extracted from the `Authorization` header, or `undefined` if not set.
*/
export function getRequestToken(request) {
const auth = request.headers.get("Authorization");
if (auth?.startsWith("Bearer "))
return auth.substring(7).trim() || undefined;
}
/**
* Extract the `Authorization: Bearer {token}` from a `Request` object, or throw `UnauthorizedError` if not set or malformed.
*
* @param request The `Request` object containing an `Authorization: Bearer {token}` header to extract the token from.
* @returns The string token extracted from the `Authorization` header.
* @throws UnauthorizedError If the `Authorization` header is not set, or the JWT it contains is not well-formed.
*/
export function requireRequestToken(request, caller = requireRequestToken) {
const token = getRequestToken(request);
if (!token)
throw new UnauthorizedError("JWT is required", { received: request.headers.get("Authorization"), caller });
return token;
}
/**
* Extract the `Authorization: Bearer {token}` from a `Request` object and verify it using a signature, or throw `UnauthorizedError` if not set, malformed, or invalid.
* - Same as doing `requireRequestToken(request)` and then `verifyToken(token, secret)`.
*
* @param request The `Request` object containing an `Authorization: Bearer {token}` header to extract the token from.
* @param secret The secret key to verify the JWT signature with.
*
* @returns The decoded payload data from the JWT.
* @throws UnauthorizedError If the `Authorization` header is not set, the JWT it contains is not well-formed, or the JWT signature is invalid.
*
* @example `const { sub, iss, customClaim } = await verifyRequestToken(request, secret);`
*/
export function verifyRequestToken(request, secret, caller = verifyRequestToken) {
const token = requireRequestToken(request, caller);
return verifyToken(token, secret, caller);
}