feat(CSRF) implement Bun.CSRF (#18045)

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
Ciro Spaciari
2025-03-10 17:51:57 -07:00
committed by GitHub
parent a9ca465ad0
commit 013fdddc6e
10 changed files with 630 additions and 7 deletions

View File

@@ -2309,10 +2309,68 @@ declare module "bun" {
*/
interface SavepointSQL extends SQL {}
type CSRFAlgorithm = "blake2b256" | "blake2b512" | "sha256" | "sha384" | "sha512" | "sha512-256";
interface CSRFGenerateOptions {
/**
* The number of milliseconds until the token expires. 0 means the token never expires.
* @default 24 * 60 * 60 * 1000 (24 hours)
*/
expiresIn?: number;
/**
* The encoding of the token.
* @default "base64url"
*/
encoding?: "base64" | "base64url" | "hex";
/**
* The algorithm to use for the token.
* @default "sha256"
*/
algorithm?: CSRFAlgorithm;
}
interface CSRFVerifyOptions {
/**
* The secret to use for the token. If not provided, a random default secret will be generated in memory and used.
*/
secret?: string;
/**
* The encoding of the token.
* @default "base64url"
*/
encoding?: "base64" | "base64url" | "hex";
/**
* The algorithm to use for the token.
* @default "sha256"
*/
algorithm?: CSRFAlgorithm;
/**
* The number of milliseconds until the token expires. 0 means the token never expires.
* @default 24 * 60 * 60 * 1000 (24 hours)
*/
maxAge?: number;
}
interface CSRF {
/**
* Generate a CSRF token.
* @param secret The secret to use for the token. If not provided, a random default secret will be generated in memory and used.
* @param options The options for the token.
* @returns The generated token.
*/
generate(secret?: string, options?: CSRFGenerateOptions): string;
/**
* Verify a CSRF token.
* @param token The token to verify.
* @param options The options for the token.
* @returns True if the token is valid, false otherwise.
*/
verify(token: string, options?: CSRFVerifyOptions): boolean;
}
var sql: SQL;
var postgres: SQL;
var SQL: SQL;
var CSRF: CSRF;
/**
* This lets you use macros as regular imports
* @example
@@ -2657,7 +2715,7 @@ declare module "bun" {
loader?: { [k in string]: Loader };
/**
* Specifies if and how to generate source maps.
*
*
* - `"none"` - No source maps are generated
* - `"linked"` - A separate `*.ext.map` file is generated alongside each
* `*.ext` file. A `//# sourceMappingURL` comment is added to the output
@@ -2665,11 +2723,11 @@ declare module "bun" {
* - `"inline"` - an inline source map is appended to the output file.
* - `"external"` - Generate a separate source map file for each input file.
* No `//# sourceMappingURL` comment is added to the output file.
*
*
* `true` and `false` are aliasees for `"inline"` and `"none"`, respectively.
*
*
* @default "none"
*
*
* @see {@link outdir} required for `"linked"` maps
* @see {@link publicPath} to customize the base url of linked source maps
*/
@@ -2704,10 +2762,10 @@ declare module "bun" {
env?: "inline" | "disable" | `${string}*`;
/**
* Whether to enable minification.
*
*
* Use `true`/`false` to enable/disable all minification options. Alternatively,
* you can pass an object for granular control over certain minifications.
*
*
* @default false
*/
minify?:

View File

@@ -128,6 +128,8 @@ pub const Features = struct {
pub var process_dlopen: usize = 0;
pub var postgres_connections: usize = 0;
pub var s3: usize = 0;
pub var csrf_verify: usize = 0;
pub var csrf_generate: usize = 0;
comptime {
@export(&napi_module_register, .{ .name = "Bun__napi_module_register_count" });

View File

@@ -44,6 +44,7 @@ pub const BunObject = struct {
// --- Getters ---
pub const CryptoHasher = toJSGetter(Crypto.CryptoHasher.getter);
pub const CSRF = toJSGetter(Bun.getCSRFObject);
pub const FFI = toJSGetter(Bun.FFIObject.getter);
pub const FileSystemRouter = toJSGetter(Bun.getFileSystemRouter);
pub const Glob = toJSGetter(Bun.getGlobConstructor);
@@ -101,6 +102,7 @@ pub const BunObject = struct {
// --- Getters ---
@export(&BunObject.CryptoHasher, .{ .name = getterName("CryptoHasher") });
@export(&BunObject.CSRF, .{ .name = getterName("CSRF") });
@export(&BunObject.FFI, .{ .name = getterName("FFI") });
@export(&BunObject.FileSystemRouter, .{ .name = getterName("FileSystemRouter") });
@export(&BunObject.MD4, .{ .name = getterName("MD4") });
@@ -4422,6 +4424,30 @@ pub fn stringWidth(str: bun.String, opts: gen.StringWidthOptions) usize {
/// EnvironmentVariables is runtime defined.
/// Also, you can't iterate over process.env normally since it only exists at build-time otherwise
pub fn getCSRFObject(globalObject: *JSC.JSGlobalObject, _: *JSC.JSObject) JSC.JSValue {
return CSRFObject.create(globalObject);
}
const CSRFObject = struct {
pub fn create(globalThis: *JSC.JSGlobalObject) JSC.JSValue {
const object = JSValue.createEmptyObject(globalThis, 2);
object.put(
globalThis,
ZigString.static("generate"),
JSC.createCallback(globalThis, ZigString.static("generate"), 1, @import("../../csrf.zig").csrf__generate),
);
object.put(
globalThis,
ZigString.static("verify"),
JSC.createCallback(globalThis, ZigString.static("verify"), 1, @import("../../csrf.zig").csrf__verify),
);
return object;
}
};
// This is aliased to Bun.env
pub const EnvironmentVariables = struct {
pub export fn Bun__getEnvCount(globalObject: *JSC.JSGlobalObject, ptr: *[*][]const u8) usize {

View File

@@ -33,6 +33,7 @@
macro(embeddedFiles) \
macro(S3Client) \
macro(s3) \
macro(CSRF) \
// --- Callbacks ---
#define FOR_EACH_CALLBACK(macro) \

View File

@@ -711,6 +711,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
embeddedFiles BunObject_getter_wrap_embeddedFiles DontDelete|PropertyCallback
S3Client BunObject_getter_wrap_S3Client DontDelete|PropertyCallback
s3 BunObject_getter_wrap_s3 DontDelete|PropertyCallback
CSRF BunObject_getter_wrap_CSRF DontDelete|PropertyCallback
allocUnsafe BunObject_callback_allocUnsafe DontDelete|Function 1
argv BunObject_getter_wrap_argv DontDelete|PropertyCallback
build BunObject_callback_build DontDelete|Function 1

View File

@@ -316,7 +316,7 @@ pub fn createCallback(
comptime functionPointer: anytype,
) JSValue {
if (@TypeOf(functionPointer) == JSC.JSHostFunctionType) {
return NewRuntimeFunction(globalObject, symbolName, argCount, functionPointer, false, false);
return NewRuntimeFunction(globalObject, symbolName, argCount, functionPointer, false, false, null);
}
return NewRuntimeFunction(globalObject, symbolName, argCount, toJSHostFunction(functionPointer), false, false, null);
}

View File

@@ -51,6 +51,7 @@ temp_pipe_read_buffer: ?*PipeReadBuffer = null,
aws_signature_cache: AWSSignatureCache = .{},
s3_default_client: JSC.Strong = .empty,
default_csrf_secret: []const u8 = "",
const PipeReadBuffer = [256 * 1024]u8;
const DIGESTED_HMAC_256_LEN = 32;
@@ -475,6 +476,15 @@ pub fn s3DefaultClient(rare: *RareData, globalThis: *JSC.JSGlobalObject) JSC.JSV
};
}
pub fn defaultCSRFSecret(this: *RareData) []const u8 {
if (this.default_csrf_secret.len == 0) {
const secret = bun.default_allocator.alloc(u8, 16) catch bun.outOfMemory();
bun.rand(secret);
this.default_csrf_secret = secret;
}
return this.default_csrf_secret;
}
pub fn deinit(this: *RareData) void {
if (this.temp_pipe_read_buffer) |pipe| {
this.temp_pipe_read_buffer = null;
@@ -487,6 +497,9 @@ pub fn deinit(this: *RareData) void {
if (this.boring_ssl_engine) |engine| {
_ = bun.BoringSSL.c.ENGINE_free(engine);
}
if (this.default_csrf_secret.len > 0) {
bun.default_allocator.free(this.default_csrf_secret);
}
this.cleanup_hooks.clearAndFree(bun.default_allocator);
}

View File

@@ -149,6 +149,7 @@ pub const patch = @import("./patch.zig");
pub const ini = @import("./ini.zig");
pub const Bitflags = @import("./bitflags.zig").Bitflags;
pub const css = @import("./css/css_parser.zig");
pub const csrf = @import("./csrf.zig");
pub const validators = @import("./bun.js/node/util/validators.zig");
pub const shell = @import("./shell/shell.zig");

377
src/csrf.zig Normal file
View File

@@ -0,0 +1,377 @@
const bun = @import("root").bun;
const std = @import("std");
const JSC = bun.JSC;
const boring = bun.BoringSSL.c;
const hmac = @import("hmac.zig");
const string = @import("string.zig");
const gen = bun.gen.csrf;
/// CSRF Token implementation for Bun
/// It provides protection against Cross-Site Request Forgery attacks
/// by generating and validating tokens using HMAC signatures
pub const CSRF = @This();
/// Default expiration time for tokens (24 hours)
pub const DEFAULT_EXPIRATION_MS: u64 = 24 * 60 * 60 * 1000;
/// Default HMAC algorithm used for token signing
pub const DEFAULT_ALGORITHM: JSC.API.Bun.Crypto.EVP.Algorithm = .sha256;
/// Error types for CSRF operations
pub const Error = error{
InvalidToken,
ExpiredToken,
TokenCreationFailed,
DecodingFailed,
};
/// Options for generating CSRF tokens
pub const GenerateOptions = struct {
/// Secret key to use for signing
secret: []const u8,
/// How long the token should be valid (in milliseconds)
expires_in_ms: u64 = DEFAULT_EXPIRATION_MS,
/// Format to encode the token in
encoding: TokenFormat = .base64url,
/// Algorithm to use for signing
algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM,
};
/// Options for validating CSRF tokens
pub const VerifyOptions = struct {
/// The token to verify
token: []const u8,
/// Secret key used to sign the token
secret: []const u8,
/// Maximum age of the token in milliseconds
max_age_ms: u64 = DEFAULT_EXPIRATION_MS,
/// Encoding to use for the token
encoding: TokenFormat = .base64url,
/// Algorithm to use for signing
algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM,
};
/// Token encoding format
pub const TokenFormat = enum {
base64,
base64url,
hex,
pub fn toNodeEncoding(self: TokenFormat) JSC.Node.Encoding {
return switch (self) {
.base64 => .base64,
.base64url => .base64url,
.hex => .hex,
};
}
};
/// Generate a new CSRF token
///
/// Parameters:
/// - options: Configuration for token generation
/// - vm: The JSC virtual machine context
///
/// Returns: A string.Slice containing the encoded token
pub fn generate(
options: GenerateOptions,
out_buffer: *[512]u8,
) ![]u8 {
// Generate nonce from entropy
var nonce: [16]u8 = undefined;
bun.rand(&nonce);
// Current timestamp in milliseconds
const timestamp = std.time.milliTimestamp();
const timestamp_u64: u64 = @bitCast(@as(i64, timestamp));
// Write timestamp to out_buffer
var timestamp_bytes: [8]u8 = undefined;
std.mem.writeInt(u64, &timestamp_bytes, timestamp_u64, .big);
var expires_in_bytes: [8]u8 = undefined;
std.mem.writeInt(u64, &expires_in_bytes, options.expires_in_ms, .big);
// Prepare payload for signing: timestamp|nonce
var payload_buf: [32]u8 = undefined; // 8 (timestamp) + 16 (nonce)
@memcpy(payload_buf[0..8], &timestamp_bytes);
@memcpy(payload_buf[8..24], &nonce);
@memcpy(payload_buf[24..32], &expires_in_bytes);
// Sign the payload
var digest_buf: [boring.EVP_MAX_MD_SIZE]u8 = undefined;
const digest = hmac.generate(options.secret, &payload_buf, options.algorithm, &digest_buf) orelse
return Error.TokenCreationFailed;
// Create the final token: timestamp|nonce|expires_in|signature in out_buffer
@memcpy(out_buffer[0..8], &timestamp_bytes);
@memcpy(out_buffer[8..24], &nonce);
@memcpy(out_buffer[24..32], &expires_in_bytes);
@memcpy(out_buffer[32 .. 32 + digest.len], digest);
// Return slice of the output buffer with the final token
return out_buffer[0 .. 32 + digest.len];
}
/// Validate a CSRF token
///
/// Parameters:
/// - options: Configuration for token validation
///
/// Returns: true if valid, false if invalid
pub fn verify(options: VerifyOptions) bool {
// Detect the encoding format
const encoding: TokenFormat = options.encoding;
// Allocate output buffer for decoded data
var buf: [boring.EVP_MAX_MD_SIZE + 32]u8 = undefined;
var token = options.token;
// check if ends with \0
if (token.len > 0 and token[token.len - 1] == 0) {
token = token[0 .. token.len - 1];
}
const decoded: []const u8 = brk: switch (encoding) {
// shares same decoder but encoder is different see encoding.zig
.base64url, .base64 => {
// do the same as Buffer.from(token, "base64url" | "base64")
const slice = bun.strings.trim(token, "\r\n\t " ++ [_]u8{std.ascii.control_code.vt});
if (slice.len == 0) return false;
const outlen = bun.base64.decodeLen(slice);
if (outlen > buf.len) return false;
const wrote = bun.base64.decode(buf[0..outlen], slice).count;
break :brk buf[0..wrote];
},
.hex => {
if (token.len % 2 != 0) return false;
// decoded len
const decoded_len = token.len / 2;
if (decoded_len > buf.len) return false;
const result = bun.strings.decodeHexToBytesTruncate(buf[0..decoded_len], u8, token);
if (result == decoded_len) {
break :brk buf[0..decoded_len];
}
return false;
},
};
// Minimum token length: 8 (timestamp) + 16 (nonce) + 8 (expires_in) + 32 (minimum HMAC-SHA256 size)
if (decoded.len < 64) {
return false;
}
// Extract timestamp (first 8 bytes)
const timestamp = std.mem.readInt(u64, decoded[0..8], .big);
// Check if token has expired
const current_time = @as(u64, @bitCast(std.time.milliTimestamp()));
// Extract expires_in (last 8 bytes)
const expires_in = std.mem.readInt(u64, decoded[24..32], .big);
{
// respect the token's expiration time
if (expires_in > 0) {
if (current_time > timestamp + expires_in) {
return false;
}
}
}
{
// repect options.max_age_ms
const expiry = options.max_age_ms;
if (expiry > 0) {
if (current_time > timestamp + expiry) {
return false;
}
}
}
// Extract the parts
const payload = decoded[0..32]; // timestamp + nonce + expires_in
const received_signature = decoded[32..];
// Verify the signature
var expected_signature: [boring.EVP_MAX_MD_SIZE]u8 = undefined;
const signature = hmac.generate(options.secret, payload, options.algorithm, &expected_signature) orelse
return false;
// Compare signatures in constant time
if (received_signature.len != signature.len) {
return false;
}
// Use BoringSSL's constant-time comparison to prevent timing attacks
return boring.CRYPTO_memcmp(
received_signature.ptr,
signature.ptr,
signature.len,
) == 0;
}
/// JS binding function for generating CSRF tokens
/// First argument is secret (required), second is options (optional)
pub fn csrf__generate_impl(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue {
if (bun.Analytics.Features.csrf_generate < std.math.maxInt(usize))
bun.Analytics.Features.csrf_generate += 1;
// We should have at least one argument (secret)
const args = callframe.arguments();
var secret: ?JSC.ZigString.Slice = null;
if (args.len >= 1) {
const jsSecret = args[0];
// Extract the secret (required)
if (jsSecret.isEmptyOrUndefinedOrNull()) {
return globalObject.throwInvalidArguments("Secret is required", .{});
}
if (!jsSecret.isString() or jsSecret.getLength(globalObject) == 0) {
return globalObject.throwInvalidArguments("Secret must be a non-empty string", .{});
}
secret = try jsSecret.toSlice(globalObject, bun.default_allocator);
}
defer if (secret) |s| s.deinit();
// Default values
var expires_in: u64 = DEFAULT_EXPIRATION_MS;
var encoding: TokenFormat = .base64url;
var algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM;
// Check if we have options object
if (args.len > 1 and args[1].isObject()) {
const options_value = args[1];
// Extract expiresIn (optional)
if (try options_value.get(globalObject, "expiresIn")) |expires_in_js| {
expires_in = @intCast(try globalObject.validateIntegerRange(expires_in_js, i64, 0, .{ .min = 0, .max = JSC.MAX_SAFE_INTEGER }));
}
// Extract encoding (optional)
if (try options_value.get(globalObject, "encoding")) |encoding_js| {
const encoding_enum = try JSC.Node.Encoding.fromJSWithDefaultOnEmpty(encoding_js, globalObject, .base64url) orelse {
return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{});
};
encoding = switch (encoding_enum) {
.base64 => .base64,
.base64url => .base64url,
.hex => .hex,
else => return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{}),
};
}
if (try options_value.get(globalObject, "algorithm")) |algorithm_js| {
if (!algorithm_js.isString()) {
return globalObject.throwInvalidArgumentTypeValue("algorithm", "string", algorithm_js);
}
algorithm = JSC.API.Bun.Crypto.EVP.Algorithm.map.fromJSCaseInsensitive(globalObject, algorithm_js) orelse {
return globalObject.throwInvalidArguments("Algorithm not supported", .{});
};
switch (algorithm) {
.blake2b256, .blake2b512, .sha256, .sha384, .sha512, .@"sha512-256" => {},
else => return globalObject.throwInvalidArguments("Algorithm not supported", .{}),
}
}
}
// Buffer for token generation
var token_buffer: [512]u8 = undefined;
// Generate the token
const token_bytes = generate(.{
.secret = if (secret) |s| s.slice() else globalObject.bunVM().rareData().defaultCSRFSecret(),
.expires_in_ms = expires_in,
.encoding = encoding,
.algorithm = algorithm,
}, &token_buffer) catch |err| {
return switch (err) {
Error.TokenCreationFailed => globalObject.throw("Failed to create CSRF token", .{}),
else => globalObject.throwError(err, "Failed to generate CSRF token"),
};
};
// Encode the token
return encoding.toNodeEncoding().encodeWithMaxSize(globalObject, boring.EVP_MAX_MD_SIZE + 32, token_bytes);
}
pub const csrf__generate: JSC.JSHostFunctionType = JSC.toJSHostFunction(csrf__generate_impl);
/// JS binding function for verifying CSRF tokens
/// First argument is token (required), second is options (optional)
pub fn csrf__verify_impl(globalObject: *JSC.JSGlobalObject, call_frame: *JSC.CallFrame) bun.JSError!JSC.JSValue {
if (bun.Analytics.Features.csrf_verify < std.math.maxInt(usize)) {
bun.Analytics.Features.csrf_verify += 1;
}
// We should have at least one argument (token)
const args = call_frame.arguments();
if (args.len < 1) {
return globalObject.throwInvalidArguments("Missing required token parameter", .{});
}
const jsToken: JSC.JSValue = args[0];
// Extract the token (required)
if (jsToken.isUndefinedOrNull()) {
return globalObject.throwInvalidArguments("Token is required", .{});
}
if (!jsToken.isString() or jsToken.getLength(globalObject) == 0) {
return globalObject.throwInvalidArguments("Token must be a non-empty string", .{});
}
const token = try jsToken.toSlice(globalObject, bun.default_allocator);
defer token.deinit();
// Default values
var secret: ?JSC.ZigString.Slice = null;
defer if (secret) |s| s.deinit();
var max_age: u64 = DEFAULT_EXPIRATION_MS;
var encoding: TokenFormat = .base64url;
var algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM;
// Check if we have options object
if (args.len > 1 and args[1].isObject()) {
const options_value = args[1];
// Extract the secret (required)
if (try options_value.getOptional(globalObject, "secret", JSC.ZigString.Slice)) |secretSlice| {
if (secretSlice.len == 0) {
return globalObject.throwInvalidArguments("Secret must be a non-empty string", .{});
}
secret = secretSlice;
}
// Extract maxAge (optional)
if (try options_value.get(globalObject, "maxAge")) |max_age_js| {
max_age = @intCast(try globalObject.validateIntegerRange(max_age_js, i64, 0, .{ .min = 0, .max = JSC.MAX_SAFE_INTEGER }));
}
// Extract encoding (optional)
if (try options_value.get(globalObject, "encoding")) |encoding_js| {
const encoding_enum = try JSC.Node.Encoding.fromJSWithDefaultOnEmpty(encoding_js, globalObject, .base64url) orelse {
return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{});
};
encoding = switch (encoding_enum) {
.base64 => .base64,
.base64url => .base64url,
.hex => .hex,
else => return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{}),
};
}
if (try options_value.get(globalObject, "algorithm")) |algorithm_js| {
if (!algorithm_js.isString()) {
return globalObject.throwInvalidArgumentTypeValue("algorithm", "string", algorithm_js);
}
algorithm = JSC.API.Bun.Crypto.EVP.Algorithm.map.fromJSCaseInsensitive(globalObject, algorithm_js) orelse {
return globalObject.throwInvalidArguments("Algorithm not supported", .{});
};
switch (algorithm) {
.blake2b256, .blake2b512, .sha256, .sha384, .sha512, .@"sha512-256" => {},
else => return globalObject.throwInvalidArguments("Algorithm not supported", .{}),
}
}
}
// Verify the token
const is_valid = verify(.{
.token = token.slice(),
.secret = if (secret) |s| s.slice() else globalObject.bunVM().rareData().defaultCSRFSecret(),
.max_age_ms = max_age,
.encoding = encoding,
.algorithm = algorithm,
});
return JSC.JSValue.jsBoolean(is_valid);
}
pub const csrf__verify: JSC.JSHostFunctionType = JSC.toJSHostFunction(csrf__verify_impl);

View File

@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test";
import { CSRF, type CSRFAlgorithm } from "bun";
describe("Bun.CSRF", () => {
const secret = "this-is-my-super-secure-secret-key";
test("CSRF exists", () => {
expect(CSRF).toBeDefined();
expect(typeof CSRF).toBe("object");
expect(typeof CSRF.generate).toBe("function");
expect(typeof CSRF.verify).toBe("function");
});
test("generates a token with default options", () => {
const token = CSRF.generate(secret);
expect(typeof token).toBe("string");
expect(token.length).toBeGreaterThan(0);
// Should be a base64url token (contains only base64url-safe characters)
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
test("generates a token with different formats", () => {
// Base64 format
const base64Token = CSRF.generate(secret, { encoding: "base64" });
expect(typeof base64Token).toBe("string");
expect(base64Token).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
// Hex format
const hexToken = CSRF.generate(secret, { encoding: "hex" });
expect(typeof hexToken).toBe("string");
expect(hexToken).toMatch(/^[0-9a-f]+$/);
});
test("verifies a valid token", () => {
const token = CSRF.generate(secret);
const isValid = CSRF.verify(token, { secret });
expect(isValid).toBe(true);
});
test("rejects an invalid token", () => {
const token = CSRF.generate(secret);
// Tamper with the token
const tamperedToken = token.substring(0, token.length - 5) + "XXXXX";
const isValid = CSRF.verify(tamperedToken, { secret });
expect(isValid).toBe(false);
});
test("token verification is sensitive to the secret", () => {
const token = CSRF.generate(secret);
// Try to verify with a different secret
const isValid = CSRF.verify(token, { secret: "wrong-secret" });
expect(isValid).toBe(false);
});
test("tokens expire after the specified time", async () => {
// Generate a token with a very short expiration (1 millisecond)
const token = CSRF.generate(secret, {
expiresIn: 1,
});
// Wait a bit to ensure expiration
await Bun.sleep(10);
// Should be expired now
const isValid = CSRF.verify(token, { secret });
expect(isValid).toBe(false);
});
test("verification respects maxAge parameter", async () => {
// Generate a token with default expiration (24 hours)
const token = CSRF.generate(secret);
// But verify with a very short maxAge (1 millisecond)
await Bun.sleep(10);
// Should be rejected because our maxAge is very short
const isValid = CSRF.verify(token, { secret, maxAge: 1 });
expect(isValid).toBe(false);
});
test("token with expiresIn parameter works", async () => {
// Generate a token with a longer expiration (1 second)
const token = CSRF.generate(secret, {
expiresIn: 100,
});
// Should be valid immediately
expect(CSRF.verify(token, { secret })).toBe(true);
// Should still be valid after a short time
await Bun.sleep(10);
expect(CSRF.verify(token, { secret })).toBe(true);
// Ensure that expiration works properly
await Bun.sleep(100);
expect(CSRF.verify(token, { secret })).toBe(false);
});
test("token format doesn't affect verification", () => {
// Test that tokens in different formats can all be verified
const base64Token = CSRF.generate(secret, { encoding: "base64" });
const base64urlToken = CSRF.generate(secret, { encoding: "base64url" });
const hexToken = CSRF.generate(secret, { encoding: "hex" });
expect(CSRF.verify(base64Token, { secret, encoding: "base64" })).toBe(true);
expect(CSRF.verify(base64urlToken, { secret, encoding: "base64url" })).toBe(true);
expect(CSRF.verify(hexToken, { secret, encoding: "hex" })).toBe(true);
});
test("test with default algorithm", async () => {
// default
const token = CSRF.generate(secret);
expect(CSRF.verify(token, { secret })).toBe(true);
});
const algorithms: Array<CSRFAlgorithm> = ["blake2b256", "blake2b512", "sha256", "sha384", "sha512", "sha512-256"];
for (const algorithm of algorithms) {
test(`test with algorithm ${algorithm}`, async () => {
const token2 = CSRF.generate(secret, { algorithm });
expect(CSRF.verify(token2, { secret, algorithm })).toBe(true);
});
}
test("default secret", () => {
const token = CSRF.generate();
expect(token).toBeDefined();
expect(token.length).toBeGreaterThan(0);
expect(CSRF.verify(token, { secret: "wrong-secret" })).toBe(false);
expect(CSRF.verify(token)).toBe(true);
});
test("error handling", () => {
// Empty token
expect(() => CSRF.verify("", { secret })).toThrow();
// Empty secret for generation
expect(() => CSRF.generate("")).toThrow();
// Empty secret for verification
expect(() => CSRF.verify("some-token", { secret: "" })).toThrow();
});
});