UNPKG

@nartix/edge-token

Version:

An edge runtime compatible token generator

483 lines (482 loc) 17.5 kB
/** * Encodes a Uint8Array into a Base64URL string using edge runtime compatible methods. * * @param {Uint8Array} data - The Uint8Array to encode. * @returns {string} - The Base64URL-encoded string. * @throws {Error} - If Base64 encoding is not supported. */ export function encodeBase64(data) { if (typeof btoa !== 'function') { throw new Error('Base64 encoding is not supported in this environment.'); } // Convert Uint8Array to binary string const binary = Array.from(data) .map((byte) => String.fromCharCode(byte)) .join(''); // Encode binary string to Base64 const base64 = btoa(binary); const base64urlMapping = { '+': '-', '/': '_', '=': '', }; const base64urlsafe = base64 .replace(/[+/=]/g, (match) => { return base64urlMapping[match] || ''; }) .replace(/=+$/, ''); return base64urlsafe; } /** * Decodes a Base64URL string into a Uint8Array using edge runtime compatible methods. * * @param {string} base64url - The Base64URL-encoded string to decode. * @returns {Uint8Array} - The decoded Uint8Array. * @throws {Error} - If the Base64URL string is invalid or decoding fails. */ export function decodeBase64(base64url) { if (typeof atob !== 'function') { throw new Error('Base64 decoding is not supported in this environment.'); } try { const base64UrlToBase64Mapping = { '-': '+', _: '/', }; // Convert Base64URL to Base64 by replacing characters let base64 = base64url.replace(/[-_]/g, (match) => { return base64UrlToBase64Mapping[match] || match; }); // Pad with '=' to make the length a multiple of 4 const paddingNeeded = 4 - (base64.length % 4); if (paddingNeeded !== 4) { base64 += '='.repeat(paddingNeeded); } // Decode Base64 string to binary string const binaryString = atob(base64); // Convert binary string to Uint8Array const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } catch (error) { throw new Error('Invalid Base64URL string provided for decoding.'); } } export const defaultDataSerializer = (data) => { if (typeof data === 'string') { return new TextEncoder().encode(data); } else if (typeof data === 'object' && data !== null && !(data instanceof Uint8Array)) { return new TextEncoder().encode(JSON.stringify(data)); } else if (data instanceof Uint8Array) { return data; } else { return new TextEncoder().encode(String(data)); } }; export const defaultDataDecoder = (data) => { const decodedString = new TextDecoder().decode(data); try { const parsedData = JSON.parse(decodedString); return parsedData; } catch (e) { // If it's not JSON, return the decoded string return decodedString; } }; const defaultOptions = { secret: '', algorithm: 'SHA-256', tokenByteLength: 32, seperator: '.', dataSerializer: defaultDataSerializer, dataDecoder: defaultDataDecoder, }; /** * Merge user provided CsrfOptions with the default options. */ export function mergeExtendedOptions(userOptions) { return { ...defaultOptions, ...userOptions, }; } /** * Derive a CryptoKey from a secret and an HMAC algorithm using the Web Crypto API. */ export async function getHmacKey(secret, algorithm) { const enc = new TextEncoder(); const keyData = enc.encode(secret); return crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: algorithm }, false, ['sign', 'verify']); } /** * Generates cryptographically secure random bytes. * * @param byteLength - The number of random bytes to generate. * @returns A Uint8Array containing random bytes. */ function generateRandomBytes(byteLength) { const randomBytes = new Uint8Array(byteLength); crypto.getRandomValues(randomBytes); return randomBytes; } /** * Combines multiple Uint8Arrays into a single Uint8Array. * * @param arrays - An array of Uint8Arrays to combine. * @returns A new Uint8Array containing all combined bytes. */ function combineUint8Arrays(...arrays) { const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); const combined = new Uint8Array(totalLength); let offset = 0; for (const arr of arrays) { combined.set(arr, offset); offset += arr.length; } return combined; } /** * Appends a part to the token parts array if the condition is met. * * @param parts - The array of token parts. * @param condition - The condition to check. * @param part - The part to append if the condition is true. */ function appendPartIf(parts, condition, part) { if (condition) { parts.push(part); } } /** * Creates a timestamp byte array if the token is timed. * * @param timed - Whether the token is timed. * @param serializer - Function to serialize the timestamp. * @returns A Uint8Array containing the timestamp or an empty array. */ function createTimestampBytes(timed, serializer) { if (timed) { const now = Date.now(); return serializer(now); } return new Uint8Array(); } /** * Signs the combined data using the provided HMAC key. * * @param key - The CryptoKey used for signing. * @param data - The data to sign. * @returns A Promise that resolves to the signature as a Uint8Array. */ async function signData(key, data) { const signatureBuffer = await crypto.subtle.sign('HMAC', key, data); return new Uint8Array(signatureBuffer); } /** * Generates a secure token by combining random bytes, optional data, and a signature. * * @param key - The CryptoKey used for HMAC signing. * @param data - Optional data to include in the token. * @param showData - Whether to include the serialized data in the token. * @param timed - Whether the token should include a timestamp. * @param tokenByteLength - The length of the random byte segment. * @param seperator - The character used to separate token parts. * @param serializer - Function to serialize data into Uint8Array. * @returns A Promise that resolves to the generated token string. */ export async function generateToken(key, data, showData, timed, tokenByteLength, seperator, serializer) { const parts = []; // Generate random bytes const randomBytes = generateRandomBytes(tokenByteLength); // Serialize data if provided const dataBytes = data !== undefined && data !== null ? serializer(data) : new Uint8Array(); // Append serialized data if showData is true and data exists appendPartIf(parts, showData && dataBytes.length > 0, encodeBase64(dataBytes)); // Create timestamp bytes if timed const timestampBytes = createTimestampBytes(timed, serializer); // Combine random bytes, data bytes, and timestamp bytes const combined = combineUint8Arrays(randomBytes, dataBytes, timestampBytes); // Append timestamp to parts if timed appendPartIf(parts, timed, encodeBase64(timestampBytes)); const signature = await signData(key, combined); // Append random bytes and signature to parts parts.push(encodeBase64(randomBytes)); parts.push(encodeBase64(signature)); return parts.join(seperator); } /** * Splits and trims the token into its constituent parts. * * @param token - The token string to split. * @param seperator - The seperator used in the token. * @returns An array of trimmed token parts. */ function splitAndTrimToken(token, seperator) { return token.split(seperator).map((part) => part.trim()); } /** * Extracts token parts based on the presence of data and timestamp. * * @param parts - The array of token parts. * @param showData - Whether the token includes data. * @param timed - Whether the token includes a timestamp. * @returns An object containing the extracted Base64 parts. */ function extractTokenParts(parts, showData, timed) { let idx = 0; let dataBase64; let timeBase64; let randomBase64; let signatureBase64; // If showData is true, the first part is the base64-encoded data. if (showData) { if (idx >= parts.length) return null; dataBase64 = parts[idx++] || ''; } // If timed is true, the next part is the base64-encoded timestamp. if (timed) { if (idx >= parts.length) return null; timeBase64 = parts[idx++] || ''; } // Next is always the base64-encoded random bytes. if (idx >= parts.length) return null; randomBase64 = parts[idx++] || ''; // Finally, the last part is the signature. if (idx >= parts.length) return null; signatureBase64 = parts[idx++] || ''; // If there are any leftover parts, format is invalid. if (idx !== parts.length) return null; return { dataBase64, timeBase64, randomBase64, signatureBase64, }; } /** * Compares two Uint8Arrays for equality. * * @param a - The first Uint8Array. * @param b - The second Uint8Array. * @returns True if both arrays are equal, false otherwise. */ function arraysEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } /** * Extracts and verifies the timestamp from the token. * * @param timeBase64 - The Base64-encoded timestamp. * @param serializer - Function to serialize the timestamp. * @returns The timestamp as a number or null if invalid. */ function extractTimestamp(timeBase64) { try { const timeBytes = decodeBase64(timeBase64); const timeStr = defaultDataDecoder(timeBytes); const tokenTime = parseInt(timeStr, 10); if (isNaN(tokenTime)) return null; return tokenTime; } catch { return null; } } /** * Handles data bytes by decoding, serializing, and comparing with expected data. * * @param data - The expected data. * @param dataBase64 - The Base64 encoded data from the token. * @param serializer - Function to serialize data. * @returns The decoded data bytes or null if comparison fails. */ async function handleAndCompareData(data, dataBase64, serializer) { try { const extractedDataBytes = decodeBase64(dataBase64); if (!extractedDataBytes) return null; const expectedDataBytes = data !== undefined && data !== null ? serializer(data) : new Uint8Array(); if (!arraysEqual(extractedDataBytes, expectedDataBytes)) { return null; // Data mismatch } return extractedDataBytes; } catch { return null; // Invalid Base64 encoding or other errors } } /** * Verifies the integrity and validity of a submitted token. * * @param key - The CryptoKey used for HMAC verification. * @param submitted - The submitted token string. * @param data - The expected data to verify against. * @param showData - Whether the token includes data. * @param timed - Whether the token includes a timestamp. * @param seperator - The seperator used in the token. * @param serializer - Function to serialize data into Uint8Array. * @param maxAgeMs - Optional maximum age in milliseconds for token validity. * @returns A Promise that resolves to true if the token is valid, false otherwise. */ export async function verifyToken(key, submitted, data, // The 'expected' data you want to verify against showData, timed, seperator, serializer, maxAgeMs // Optional expiration check in milliseconds ) { // Split and trim the submitted token string into parts. const parts = splitAndTrimToken(submitted, seperator); // Extract token parts based on showData and timed flags. const extractedParts = extractTokenParts(parts, showData, timed); if (!extractedParts) return false; const { dataBase64, timeBase64, randomBase64, signatureBase64 } = extractedParts; let randomBytes; try { randomBytes = decodeBase64(randomBase64); } catch { return false; } // Handle data bytes. const dataBytes = showData && dataBase64 ? await handleAndCompareData(data, dataBase64, serializer) : serializer(data); if (showData && dataBase64 && dataBytes === null) { return false; } // Combine random bytes and data bytes. let finalCombined = combineUint8Arrays(randomBytes, dataBytes); let tokenTime = null; // Handle timestamp bytes if the token is timed. if (timed && timeBase64) { tokenTime = extractTimestamp(timeBase64); if (tokenTime === null) return false; // Corrupted or invalid timestamp // Serialize the timestamp as it was during token generation. const timeBytes = serializer(tokenTime); // Append the timestamp bytes to the combined data. finalCombined = combineUint8Arrays(finalCombined, timeBytes); } // Decode the signature from Base64. let signatureBytes; try { signatureBytes = decodeBase64(signatureBase64); } catch { return false; // Invalid Base64 encoding for signature } let isVerified; try { isVerified = await crypto.subtle.verify('HMAC', key, signatureBytes, finalCombined); } catch { return false; } if (!isVerified) { return false; } // Verify the token's age if maxAgeMs is provided. if (maxAgeMs && tokenTime) { const currentTime = Date.now(); if (currentTime - tokenTime > maxAgeMs) { return false; } } return true; } /** * Determines if the provided data is considered an edge case. * * @param data - The data to check. * @returns True if data is an edge case, false otherwise. */ export function isEdgeCase(data) { // const edgeCases = [undefined, null, '', 0, false, {}, []]; if (data === undefined || data === null) return true; if (data === '' || data === 0 || data === false) return true; if (typeof data === 'object') { if (Array.isArray(data)) return data.length === 0; return Object.keys(data).length === 0; } return false; } /** * Create a CSRF utility object from user options merged with defaults. * Users can generate and verify tokens that are tied to optional additional data. * Custom data serializers can be provided to handle different data types. */ export async function edgeToken(userOptions) { const options = mergeExtendedOptions(userOptions); // Ensure tokenByteLength is valid if (options.tokenByteLength <= 0) { options.tokenByteLength = defaultOptions.tokenByteLength; } const key = await getHmacKey(options.secret, options.algorithm); // Helper function to sanitize data const sanitizeData = (data) => (isEdgeCase(data) ? '' : data); // Helper function to generate token const createToken = async (data, showData, timed, tokenByteLength = options.tokenByteLength, seperator = options.seperator, dataSerializer = options.dataSerializer) => { data = sanitizeData(data); return generateToken(key, data, showData && Boolean(data), timed, tokenByteLength, seperator, dataSerializer); }; // Helper function to verify token const checkToken = async (submitted, data, showData, timed, seperator = options.seperator, dataSerializer = options.dataSerializer, maxAgeMs) => { data = sanitizeData(data); return verifyToken(key, submitted, data, showData && Boolean(data), timed, seperator, dataSerializer, maxAgeMs); }; return { options, /** * Generate a simple token without data and without timing. */ generate: () => createToken('', false, false), /** * Verify a simple token with or without data and without timing. */ verify: (submitted, data = '') => checkToken(submitted, data, false, false), /** * Generate a token with embedded data but without timing. */ generateWithData: (data) => createToken(data, Boolean(data), false), /** * Verify a token with embedded data but without timing. */ verifyWithData: (submitted, data) => checkToken(submitted, data, Boolean(data), false), /** * Generate a timed token without embedded data. */ generateTimed: (data = '') => createToken(data, false, true), /** * Verify a timed token without embedded data. * @param maxAgeMs The maximum age in milliseconds the token is valid for. */ verifyTimed: (submitted, data = '', maxAgeMs) => checkToken(submitted, data, false, true, options.seperator, options.dataSerializer, maxAgeMs), /** * Generate a timed token with embedded data. */ generateWithDataTimed: (data) => createToken(data, Boolean(data), true), /** * Verify a timed token with embedded data. * @param maxAgeMs The maximum age in milliseconds the token is valid for. */ verifyWithDataTimed: (submitted, data, maxAgeMs) => checkToken(submitted, data, Boolean(data), true, options.seperator, options.dataSerializer, maxAgeMs), }; }