├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.zig ├── build.zig.zon ├── examples └── demo │ └── main.zig └── src ├── bench.zig ├── decode.zig ├── encode.zig ├── root.zig └── validation.zig /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | release: 9 | if: startsWith(github.ref, 'refs/tags/') 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | needs: [fmt, test, examples] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Release 18 | uses: softprops/action-gh-release@v2 19 | with: 20 | draft: true 21 | fmt: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: write 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | - uses: goto-bus-stop/setup-zig@v2 29 | with: 30 | version: 0.13.0 31 | - name: fmt 32 | run: zig fmt --check . 33 | examples: 34 | runs-on: ubuntu-latest 35 | permissions: 36 | contents: write 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | - uses: goto-bus-stop/setup-zig@v2 41 | with: 42 | version: 0.13.0 43 | - name: Examples 44 | run: zig build run-demo-example 45 | test: 46 | runs-on: ubuntu-latest 47 | permissions: 48 | contents: write 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | - uses: goto-bus-stop/setup-zig@v2 53 | with: 54 | version: 0.13.0 55 | - name: Test 56 | run: zig build test --summary all 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *zig-* 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | zig 0.13.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.1.0 2 | 3 | * first release 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Doug Tangren 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | zig jwt 3 |

4 | 5 |
6 | A JWT library for zig 7 |
8 | 9 | --- 10 | 11 | [![Main](https://github.com/softprops/zig-jwt/actions/workflows/ci.yml/badge.svg)](https://github.com/softprops/zig-jwt/actions/workflows/ci.yml) ![License Info](https://img.shields.io/github/license/softprops/zig-jwt) ![Release](https://img.shields.io/github/v/release/softprops/zig-jwt) [![Zig Support](https://img.shields.io/badge/zig-0.13.0-black?logo=zig)](https://ziglang.org/documentation/0.13.0/) 12 | 13 | ## 📼 installing 14 | 15 | Create a new exec project with `zig init`. Copy an example from the examples directory into your into `src/main.zig` 16 | 17 | Create a `build.zig.zon` file to declare a dependency 18 | 19 | > .zon short for "zig object notation" files are essentially zig structs. `build.zig.zon` is zigs native package manager convention for where to declare dependencies 20 | 21 | Starting in zig 0.12.0, you can use and should prefer 22 | 23 | ```sh 24 | zig fetch --save https://github.com/softprops/zig-jwt/archive/refs/tags/v0.1.0.tar.gz 25 | ``` 26 | 27 | otherwise, to manually add it, do so as follows 28 | 29 | ```diff 30 | .{ 31 | .name = "my-app", 32 | .version = "0.1.0", 33 | .dependencies = .{ 34 | + // 👇 declare dep properties 35 | + .jwt = .{ 36 | + // 👇 uri to download 37 | + .url = "https://github.com/softprops/zig-jwt/archive/refs/tags/v0.1.0.tar.gz", 38 | + // 👇 hash verification 39 | + .hash = "...", 40 | + }, 41 | }, 42 | } 43 | ``` 44 | 45 | > the hash below may vary. you can also depend any tag with `https://github.com/softprops/zig-jwt/archive/refs/tags/v{version}.tar.gz` or current main with `https://github.com/softprops/zig-jwt/archive/refs/heads/main/main.tar.gz`. to resolve a hash omit it and let zig tell you the expected value. 46 | 47 | Add the following in your `build.zig` file 48 | 49 | ```diff 50 | const std = @import("std"); 51 | 52 | pub fn build(b: *std.Build) void { 53 | const target = b.standardTargetOptions(.{}); 54 | 55 | const optimize = b.standardOptimizeOption(.{}); 56 | // 👇 de-reference dep from build.zig.zon 57 | + const jwt = b.dependency("jwt", .{ 58 | + .target = target, 59 | + .optimize = optimize, 60 | + }).module("jwt"); 61 | var exe = b.addExecutable(.{ 62 | .name = "your-exe", 63 | .root_source_file = .{ .path = "src/main.zig" }, 64 | .target = target, 65 | .optimize = optimize, 66 | }); 67 | // 👇 add the module to executable 68 | + exe.root_mode.addImport("jwt", jwt); 69 | 70 | b.installArtifact(exe); 71 | } 72 | ``` 73 | 74 | ## examples 75 | 76 | See examples directory 77 | 78 | ## 🥹 for budding ziglings 79 | 80 | Does this look interesting but you're new to zig and feel left out? No problem, zig is young so most us of our new are as well. Here are some resources to help get you up to speed on zig 81 | 82 | - [the official zig website](https://ziglang.org/) 83 | - [zig's one-page language documentation](https://ziglang.org/documentation/0.13.0/) 84 | - [ziglearn](https://ziglearn.org/) 85 | - [ziglings exercises](https://github.com/ratfactor/ziglings) 86 | 87 | 88 | \- softprops 2024 89 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | // Although this function looks imperative, note that its job is to 4 | // declaratively construct a build graph that will be executed by an external 5 | // runner. 6 | pub fn build(b: *std.Build) !void { 7 | // Standard target options allows the person running `zig build` to choose 8 | // what target to build for. Here we do not override the defaults, which 9 | // means any target is allowed, and the default is native. Other options 10 | // for restricting supported target set are available. 11 | const target = b.standardTargetOptions(.{}); 12 | 13 | // Standard optimization options allow the person running `zig build` to select 14 | // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not 15 | // set a preferred release mode, allowing the user to decide how to optimize. 16 | const optimize = b.standardOptimizeOption(.{}); 17 | 18 | const jwt = b.addModule("jwt", .{ 19 | .root_source_file = b.path("src/root.zig"), 20 | .target = target, 21 | .optimize = optimize, 22 | }); 23 | 24 | // Creates a step for unit testing. This only builds the test executable 25 | // but does not run it. 26 | const lib_unit_tests = b.addTest(.{ 27 | .root_source_file = b.path("src/root.zig"), 28 | .target = target, 29 | .optimize = optimize, 30 | }); 31 | 32 | const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); 33 | 34 | // Similar to creating the run step earlier, this exposes a `test` step to 35 | // the `zig build --help` menu, providing a way for the user to request 36 | // running the unit tests. 37 | const test_step = b.step("test", "Run unit tests"); 38 | test_step.dependOn(&run_lib_unit_tests.step); 39 | 40 | inline for ([_]struct { 41 | name: []const u8, 42 | src: []const u8, 43 | }{ 44 | .{ .name = "demo", .src = "examples/demo/main.zig" }, 45 | }) |example| { 46 | const example_step = b.step(try std.fmt.allocPrint( 47 | b.allocator, 48 | "{s}-example", 49 | .{example.name}, 50 | ), try std.fmt.allocPrint( 51 | b.allocator, 52 | "build the {s} example", 53 | .{example.name}, 54 | )); 55 | 56 | const example_run_step = b.step(try std.fmt.allocPrint( 57 | b.allocator, 58 | "run-{s}-example", 59 | .{example.name}, 60 | ), try std.fmt.allocPrint( 61 | b.allocator, 62 | "run the {s} example", 63 | .{example.name}, 64 | )); 65 | 66 | var exe = b.addExecutable(.{ 67 | .name = example.name, 68 | .root_source_file = b.path(example.src), 69 | .target = target, 70 | .optimize = optimize, 71 | }); 72 | exe.root_module.addImport("jwt", jwt); 73 | 74 | // run the artifact - depending on the example exe 75 | const example_run = b.addRunArtifact(exe); 76 | example_run_step.dependOn(&example_run.step); 77 | 78 | // install the artifact - depending on the example exe 79 | const example_build_step = b.addInstallArtifact(exe, .{}); 80 | example_step.dependOn(&example_build_step.step); 81 | 82 | const benchmark_tests = b.addTest(.{ 83 | .root_source_file = b.path("src/bench.zig"), 84 | .target = target, 85 | .optimize = optimize, 86 | .filters = &.{"bench"}, 87 | }); 88 | const benchmark = b.dependency("benchmark", .{ 89 | .target = target, 90 | .optimize = optimize, 91 | }).module("benchmark"); 92 | benchmark_tests.root_module.addImport("benchmark", benchmark); 93 | 94 | const run_benchmark_tests = b.addRunArtifact(benchmark_tests); 95 | 96 | const benchmark_step = b.step("bench", "Run benchmark tests"); 97 | benchmark_step.dependOn(&run_benchmark_tests.step); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = "jwt", 3 | .version = "0.1.0", 4 | .minimum_zig_version = "0.13.0", 5 | .dependencies = .{ 6 | // todo: update this to a versioned tag when this package publishes one 7 | .benchmark = .{ 8 | .url = "https://github.com/silversquirl/benchmark.zig/archive/refs/heads/main/main.tar.gz", 9 | .hash = "1220287c22cfcf85f05d353084e12200c6a7dcb5f4511cb05103f5dbd709e50731a7", 10 | }, 11 | }, 12 | .paths = .{ 13 | "build.zig", 14 | "build.zig.zon", 15 | "src", 16 | "LICENSE", 17 | "README.md", 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /examples/demo/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const jwt = @import("jwt"); 3 | 4 | pub fn main() !void { 5 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 6 | defer _ = gpa.deinit(); 7 | const allocator = gpa.allocator(); 8 | 9 | // 👇 encode as a token jwt from its components 10 | const token = try jwt.encode( 11 | allocator, 12 | // 👇 header, at a minimum declaring an algorithm 13 | .{ .alg = .HS256 }, 14 | // 👇 claims 15 | .{ 16 | .sub = "demo", 17 | .exp = std.time.timestamp() * 10, 18 | .aud = "demo", 19 | }, 20 | // 👇 encoding key used to sign token 21 | .{ .secret = "secret" }, 22 | ); 23 | defer allocator.free(token); 24 | 25 | // 👇 decode token in to its respective parts 26 | var decoded = try jwt.decode( 27 | allocator, 28 | // 👇 the claims set we expect 29 | struct { sub: []const u8 }, 30 | // 👇 the raw encoded token 31 | token, 32 | // 👇 decoding key used to verify encoded token's signature 33 | .{ .secret = "secret" }, 34 | // 👇 verification rules that must hold for the token to be successfully decoded. 35 | // this includes sensible defaults. 36 | .{}, 37 | ); 38 | defer decoded.deinit(); 39 | } 40 | -------------------------------------------------------------------------------- /src/bench.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const jwt = @import("root.zig"); 3 | 4 | const benchmark = @import("benchmark"); 5 | 6 | // bench hello world comparison 7 | test "bench decode" { 8 | try benchmark.main(.{}, struct { 9 | pub fn benchDecode(b: *benchmark.B) !void { 10 | // Setup is not timed 11 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 12 | defer arena.deinit(); 13 | 14 | const token = try jwt.encode(arena.allocator(), .{ .alg = .HS256 }, .{ .sub = "test", .exp = @divTrunc(std.time.milliTimestamp(), 1000) * 10 }, .{ .secret = "secret" }); 15 | defer arena.allocator().free(token); 16 | 17 | while (b.step()) { 18 | var decoded = try jwt.decode( 19 | arena.allocator(), 20 | struct { sub: []const u8 }, 21 | token, 22 | .{ .secret = "secret" }, 23 | .{}, 24 | ); 25 | defer decoded.deinit(); 26 | 27 | // `use` is a helper that calls `std.mem.doNotOptimizeAway` 28 | b.use(decoded); 29 | } 30 | } 31 | })(); 32 | } 33 | 34 | test "bench encode" { 35 | try benchmark.main(.{}, struct { 36 | // Benchmarks are just public functions 37 | pub fn benchEncode(b: *benchmark.B) !void { 38 | // Setup is not timed 39 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 40 | defer arena.deinit(); 41 | 42 | while (b.step()) { // Number of iterations is automatically adjusted for accurate timing 43 | defer _ = arena.reset(.retain_capacity); 44 | 45 | const token = try jwt.encode(arena.allocator(), .{ .alg = .HS256 }, .{ .sub = "test", .exp = @divTrunc(std.time.milliTimestamp(), 1000) * 10 }, .{ .secret = "secret" }); 46 | defer arena.allocator().free(token); 47 | 48 | // `use` is a helper that calls `std.mem.doNotOptimizeAway` 49 | b.use(token); 50 | } 51 | } 52 | })(); 53 | } 54 | -------------------------------------------------------------------------------- /src/decode.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Validation = @import("validation.zig").Validation; 3 | const Algorithm = @import("root.zig").Algorithm; 4 | const JWT = @import("root.zig").JWT; 5 | const Header = @import("root.zig").Header; 6 | 7 | /// Key used for decoding JWT tokens 8 | pub const DecodingKey = union(enum) { 9 | secret: []const u8, 10 | edsa: std.crypto.sign.Ed25519.PublicKey, 11 | es256: std.crypto.sign.ecdsa.EcdsaP256Sha256.PublicKey, 12 | es384: std.crypto.sign.ecdsa.EcdsaP384Sha384.PublicKey, 13 | //rsa: std.crypto.Certificate.rsa.PublicKey, 14 | 15 | fn fromSecret(secret: []const u8) @This() { 16 | return .{ .secret = secret }; 17 | } 18 | 19 | fn fromEdsaBytes(bytes: [std.crypto.sign.Ed25519.PublicKey]u8) !@This() { 20 | return .{ .edsa = try std.crypto.sign.Ed25519.PublicKey.fromBytes(bytes) }; 21 | } 22 | 23 | pub fn fromEs256Bytes(bytes: [std.crypto.ecdsa.EcdsaP256Sha256.PublicKey.encoded_length]u8) !@This() { 24 | return .{ .es256 = try std.crypto.sign.ecdsa.EcdsaP256Sha256.PublicKey.fromBytes(bytes) }; 25 | } 26 | 27 | pub fn fromEs384Bytes(bytes: [std.crypto.ecdsa.EcdsaP384Sha384.PublicKey.encoded_length]u8) !@This() { 28 | return .{ .es384 = try std.crypto.sign.ecdsa.EcdsaP384Sha384.PublicKey.fromBytes(bytes) }; 29 | } 30 | }; 31 | 32 | fn decodePart(allocator: std.mem.Allocator, comptime T: type, encoded: []const u8) !T { 33 | const decoder = std.base64.url_safe_no_pad.Decoder; 34 | const dest = try allocator.alloc(u8, try decoder.calcSizeForSlice(encoded)); 35 | _ = try decoder.decode(dest, encoded); 36 | return try std.json.parseFromSliceLeaky(T, allocator, dest, .{ .allocate = .alloc_always, .ignore_unknown_fields = true }); 37 | } 38 | 39 | pub fn decode( 40 | allocator: std.mem.Allocator, 41 | comptime ClaimSet: type, 42 | str: []const u8, 43 | key: DecodingKey, 44 | validation: Validation, 45 | ) !JWT(ClaimSet) { 46 | var arena = try allocator.create(std.heap.ArenaAllocator); 47 | arena.* = std.heap.ArenaAllocator.init(allocator); 48 | errdefer { 49 | arena.deinit(); 50 | allocator.destroy(arena); 51 | } 52 | if (std.mem.count(u8, str, ".") == 2) { 53 | const sigSplit = std.mem.lastIndexOfScalar(u8, str, '.').?; 54 | const messageEnc, const signatureEnc = .{ str[0..sigSplit], str[sigSplit + 1 ..] }; 55 | 56 | const header = try decodePart(arena.allocator(), Header, messageEnc[0..std.mem.indexOfScalar(u8, messageEnc, '.').?]); 57 | const claims = try verify(arena.allocator(), header.alg, key, ClaimSet, messageEnc, signatureEnc, validation); 58 | 59 | return .{ 60 | .arena = arena, 61 | .header = header, 62 | .claims = claims, 63 | }; 64 | } 65 | return error.MalformedJWT; 66 | } 67 | 68 | pub fn verify( 69 | allocator: std.mem.Allocator, 70 | algo: Algorithm, 71 | key: DecodingKey, 72 | comptime ClaimSet: type, 73 | msg: []const u8, 74 | sigEnc: []const u8, 75 | validation: Validation, 76 | ) !ClaimSet { 77 | const decoder = std.base64.url_safe_no_pad.Decoder; 78 | const sig = try allocator.alloc(u8, try decoder.calcSizeForSlice(sigEnc)); 79 | _ = try decoder.decode(sig, sigEnc); 80 | 81 | switch (algo) { 82 | .HS256 => { 83 | var dest: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 84 | var src: [dest.len]u8 = undefined; 85 | std.crypto.auth.hmac.sha2.HmacSha256.create(&dest, msg, switch (key) { 86 | .secret => |v| v, 87 | else => return error.InvalidDecodingKey, 88 | }); 89 | @memcpy(&src, sig); 90 | if (!std.crypto.utils.timingSafeEql([dest.len]u8, src, dest)) { 91 | return error.InvalidSignature; 92 | } 93 | }, 94 | .HS384 => { 95 | var dest: [std.crypto.auth.hmac.sha2.HmacSha384.mac_length]u8 = undefined; 96 | var src: [dest.len]u8 = undefined; 97 | std.crypto.auth.hmac.sha2.HmacSha384.create(&dest, msg, switch (key) { 98 | .secret => |v| v, 99 | else => return error.InvalidDecodingKey, 100 | }); 101 | @memcpy(&src, sig); 102 | if (!std.crypto.utils.timingSafeEql([dest.len]u8, src, dest)) { 103 | return error.InvalidSignature; 104 | } 105 | }, 106 | .HS512 => { 107 | var dest: [std.crypto.auth.hmac.sha2.HmacSha512.mac_length]u8 = undefined; 108 | var src: [dest.len]u8 = undefined; 109 | std.crypto.auth.hmac.sha2.HmacSha512.create(&dest, msg, switch (key) { 110 | .secret => |v| v, 111 | else => return error.InvalidDecodingKey, 112 | }); 113 | @memcpy(&src, sig); 114 | if (!std.crypto.utils.timingSafeEql([dest.len]u8, src, dest)) { 115 | return error.InvalidSignature; 116 | } 117 | }, 118 | .ES256 => { 119 | var src: [std.crypto.sign.ecdsa.EcdsaP256Sha256.Signature.encoded_length]u8 = undefined; 120 | @memcpy(&src, sig); 121 | std.crypto.sign.ecdsa.EcdsaP256Sha256.Signature.fromBytes(src).verify(msg, switch (key) { 122 | .es256 => |v| v, 123 | else => return error.InvalidDecodingKey, 124 | }) catch { 125 | return error.InvalidSignature; 126 | }; 127 | }, 128 | .ES384 => { 129 | var src: [std.crypto.sign.ecdsa.EcdsaP384Sha384.Signature.encoded_length]u8 = undefined; 130 | @memcpy(&src, sig); 131 | std.crypto.sign.ecdsa.EcdsaP384Sha384.Signature.fromBytes(src).verify(msg, switch (key) { 132 | .es384 => |v| v, 133 | else => return error.InvalidDecodingKey, 134 | }) catch { 135 | return error.InvalidSignature; 136 | }; 137 | }, 138 | // .PS256 => { 139 | // const modulus_len = 256; 140 | // const psSig = std.crypto.Certificate.rsa.PSSSignature.fromBytes(modulus_len, sig); 141 | // std.crypto.Certificate.rsa.PSSSignature.verify(modulus_len, psSig, msg, switch (key) { 142 | // .rsa => |v| v, 143 | // else => return error.InvalidDecodingKey, 144 | // }, std.crypto.hash.sha2.Sha256) catch { 145 | // return error.InvalidSignature; 146 | // }; 147 | // }, 148 | .EdDSA => { 149 | var src: [std.crypto.sign.Ed25519.Signature.encoded_length]u8 = undefined; 150 | @memcpy(&src, sig); 151 | std.crypto.sign.Ed25519.Signature.fromBytes(src).verify(msg, switch (key) { 152 | .edsa => |v| v, 153 | else => return error.InvalidDecodingKey, 154 | }) catch { 155 | return error.InvalidSignature; 156 | }; 157 | }, 158 | 159 | // 160 | // 161 | else => return error.TODO, 162 | } 163 | 164 | try validation.validate( 165 | try decodePart(allocator, Validation.RegisteredClaims, msg[std.mem.indexOfScalar(u8, msg, '.').? + 1 ..]), 166 | ); 167 | 168 | const claims = try decodePart( 169 | allocator, 170 | ClaimSet, 171 | msg[std.mem.indexOfScalar(u8, msg, '.').? + 1 ..], 172 | ); 173 | 174 | return claims; 175 | } 176 | -------------------------------------------------------------------------------- /src/encode.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Algorithm = @import("root.zig").Algorithm; 3 | const Header = @import("root.zig").Header; 4 | 5 | /// Key used for encoding JWT token components 6 | pub const EncodingKey = union(enum) { 7 | secret: []const u8, 8 | edsa: std.crypto.sign.Ed25519.SecretKey, 9 | es256: std.crypto.sign.ecdsa.EcdsaP256Sha256.SecretKey, 10 | es384: std.crypto.sign.ecdsa.EcdsaP384Sha384.SecretKey, 11 | 12 | /// create a new edsa encoding key from edsa secret key bytes 13 | pub fn fromEdsaBytes(bytes: [std.crypto.sign.Ed25519.SecretKey.encoded_length]u8) !@This() { 14 | return .{ .edsa = try std.crypto.sign.Ed25519.SecretKey.fromBytes(bytes) }; 15 | } 16 | 17 | pub fn fromEs256Bytes(bytes: [std.crypto.ecdsa.EcdsaP256Sha256.SecretKey.encoded_length]u8) !@This() { 18 | return .{ .es256 = try std.crypto.sign.ecdsa.EcdsaP256Sha256.SecretKey.fromBytes(bytes) }; 19 | } 20 | 21 | pub fn fromEs384Bytes(bytes: [std.crypto.ecdsa.EcdsaP384Sha384.SecretKey.encoded_length]u8) !@This() { 22 | return .{ .es384 = try std.crypto.sign.ecdsa.EcdsaP384Sha384.SecretKey.fromBytes(bytes) }; 23 | } 24 | }; 25 | 26 | fn encodePart( 27 | allocator: std.mem.Allocator, 28 | part: anytype, 29 | ) ![]const u8 { 30 | const encoder = std.base64.url_safe_no_pad.Encoder; 31 | const json = try std.json.stringifyAlloc(allocator, part, .{ .emit_null_optional_fields = false }); 32 | defer allocator.free(json); 33 | const enc = try allocator.alloc(u8, encoder.calcSize(json.len)); 34 | _ = encoder.encode(enc, json); 35 | return enc; 36 | } 37 | 38 | fn sign( 39 | allocator: std.mem.Allocator, 40 | msg: []const u8, 41 | algo: Algorithm, 42 | key: EncodingKey, 43 | ) ![]const u8 { 44 | return switch (algo) { 45 | .HS256 => blk: { 46 | var dest: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 47 | std.crypto.auth.hmac.sha2.HmacSha256.create(&dest, msg, switch (key) { 48 | .secret => |v| v, 49 | else => return error.InvalidEncodingKey, 50 | }); 51 | break :blk allocator.dupe(u8, &dest); 52 | }, 53 | .HS384 => blk: { 54 | var dest: [std.crypto.auth.hmac.sha2.HmacSha384.mac_length]u8 = undefined; 55 | std.crypto.auth.hmac.sha2.HmacSha384.create(&dest, msg, switch (key) { 56 | .secret => |v| v, 57 | else => return error.InvalidEncodingKey, 58 | }); 59 | break :blk allocator.dupe(u8, &dest); 60 | }, 61 | .HS512 => blk: { 62 | var dest: [std.crypto.auth.hmac.sha2.HmacSha512.mac_length]u8 = undefined; 63 | std.crypto.auth.hmac.sha2.HmacSha512.create(&dest, msg, switch (key) { 64 | .secret => |v| v, 65 | else => return error.InvalidEncodingKey, 66 | }); 67 | break :blk allocator.dupe(u8, &dest); 68 | }, 69 | .ES256 => blk: { 70 | const pair = try std.crypto.sign.ecdsa.EcdsaP256Sha256.KeyPair.fromSecretKey(switch (key) { 71 | .es256 => |v| v, 72 | else => return error.InvalidEncodingKey, 73 | }); 74 | const dest = (try pair.sign(msg, null)).toBytes(); 75 | break :blk allocator.dupe(u8, &dest); 76 | }, 77 | .ES384 => blk: { 78 | const pair = try std.crypto.sign.ecdsa.EcdsaP384Sha384.KeyPair.fromSecretKey(switch (key) { 79 | .es384 => |v| v, 80 | else => return error.InvalidEncodingKey, 81 | }); 82 | const dest = (try pair.sign(msg, null)).toBytes(); 83 | break :blk allocator.dupe(u8, &dest); 84 | }, 85 | .EdDSA => blk: { 86 | const pair = try std.crypto.sign.Ed25519.KeyPair.fromSecretKey(switch (key) { 87 | .edsa => |v| v, 88 | else => return error.InvalidEncodingKey, 89 | }); 90 | const dest = (try pair.sign(msg, null)).toBytes(); 91 | break :blk allocator.dupe(u8, &dest); 92 | }, 93 | else => return error.TODO, 94 | }; 95 | } 96 | 97 | pub fn encode( 98 | allocator: std.mem.Allocator, 99 | header: Header, 100 | claims: anytype, 101 | key: EncodingKey, 102 | ) ![]const u8 { 103 | comptime { 104 | if (@typeInfo(@TypeOf(claims)) != .Struct) { 105 | @compileError("expected claims to be a struct but was a " ++ @typeName(@TypeOf(claims))); 106 | } 107 | } 108 | 109 | const encoder = std.base64.url_safe_no_pad.Encoder; 110 | 111 | const header_enc = try encodePart(allocator, header); 112 | defer allocator.free(header_enc); 113 | 114 | const claims_enc = try encodePart(allocator, claims); 115 | defer allocator.free(claims_enc); 116 | 117 | const msg = try std.mem.join(allocator, ".", &.{ header_enc, claims_enc }); 118 | defer allocator.free(msg); 119 | 120 | const sig = try sign(allocator, msg, header.alg, key); 121 | defer allocator.free(sig); 122 | const sig_enc = try allocator.alloc(u8, encoder.calcSize(sig.len)); 123 | defer allocator.free(sig_enc); 124 | _ = encoder.encode(sig_enc, sig); 125 | 126 | var buf = std.ArrayList(u8).init(allocator); 127 | defer buf.deinit(); 128 | try buf.appendSlice(msg); 129 | try buf.append('.'); 130 | try buf.appendSlice(sig_enc); 131 | 132 | return try buf.toOwnedSlice(); 133 | } 134 | -------------------------------------------------------------------------------- /src/root.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub const decode = @import("decode.zig").decode; 4 | pub const DecodingKey = @import("decode.zig").DecodingKey; 5 | pub const Validation = @import("validation.zig").Validation; 6 | pub const encode = @import("encode.zig").encode; 7 | pub const EncodingKey = @import("encode.zig").EncodingKey; 8 | 9 | /// A collection of commonly used signature algorithms which 10 | /// JWT adopted from JOSE specifications. 11 | /// 12 | /// For a fuller list, [this list](https://www.iana.org/assignments/jose/jose.xhtml#web-signature-encryption-algorithms). 13 | pub const Algorithm = enum { 14 | /// HMAC using SHA-256 15 | HS256, 16 | /// HMAC using SHA-384 17 | HS384, 18 | /// HMAC using SHA-512 19 | HS512, 20 | /// ECDSA using SHA-256 21 | ES256, 22 | /// ECDSA using SHA-384 23 | ES384, 24 | /// RSASSA-PKCS1-v1_5 using SHA-256 25 | RS256, 26 | /// RSASSA-PKCS1-v1_5 using SHA-384 27 | RS384, 28 | /// RSASSA-PKCS1-v1_5 using SHA-512 29 | RS512, 30 | /// RSASSA-PSS using SHA-256 31 | PS256, 32 | /// RSASSA-PSS using SHA-384 33 | PS384, 34 | /// RSASSA-PSS using SHA-512 35 | PS512, 36 | /// Edwards-curve Digital Signature Algorithm (EdDSA) 37 | EdDSA, 38 | 39 | pub fn jsonStringify( 40 | self: @This(), 41 | out: anytype, 42 | ) !void { 43 | try out.write(@tagName(self)); 44 | } 45 | }; 46 | 47 | pub const Header = struct { 48 | alg: Algorithm, 49 | typ: ?[]const u8 = null, 50 | cty: ?[]const u8 = null, 51 | jku: ?[]const u8 = null, 52 | jwk: ?[]const u8 = null, 53 | kid: ?[]const u8 = null, 54 | x5t: ?[]const u8 = null, 55 | @"x5t#S256": ?[]const u8 = null, 56 | 57 | // todo add others 58 | // 59 | pub fn format( 60 | self: @This(), 61 | comptime _: []const u8, 62 | _: std.fmt.FormatOptions, 63 | writer: anytype, 64 | ) !void { 65 | var out = std.json.writeStream(writer, .{ .emit_null_optional_fields = false }); 66 | defer out.deinit(); 67 | try out.write(self); 68 | } 69 | }; 70 | 71 | pub fn JWT(comptime ClaimSet: type) type { 72 | return struct { 73 | arena: *std.heap.ArenaAllocator, 74 | header: Header, 75 | claims: ClaimSet, 76 | 77 | pub fn deinit(self: *@This()) void { 78 | const child = self.arena.child_allocator; 79 | self.arena.deinit(); 80 | child.destroy(self.arena); 81 | } 82 | }; 83 | } 84 | 85 | test "ES256.roundrip" { 86 | const allocator = std.testing.allocator; 87 | const validation: Validation = .{ 88 | .now = struct { 89 | fn func() u64 { 90 | return 1722441274; // Wednesday, July 31, 2024 3:54:34 PM - in seconds 91 | } 92 | }.func, 93 | }; 94 | 95 | // predicable key generation 96 | var seed: [32]u8 = undefined; 97 | _ = try std.fmt.hexToBytes(seed[0..], "8052030376d47112be7f73ed7a019293dd12ad910b654455798b4667d73de166"); 98 | const pair = try std.crypto.sign.ecdsa.EcdsaP256Sha256.KeyPair.create(seed); 99 | 100 | const token = try encode( 101 | allocator, 102 | .{ .alg = .ES256 }, 103 | .{ .sub = "test", .exp = validation.now() + 60 }, 104 | .{ .es256 = pair.secret_key }, 105 | ); 106 | defer allocator.free(token); 107 | try std.testing.expectEqualStrings("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNzIyNDQxMzM0fQ.0ZiqWyJd3TKN2yB01Xhg91p8qmW-L0XrZunsHwkr2L3D79T45g8Imrqk5V5AhfLbBjqd2NPuZHcChpsSxiGtNw", token); 108 | 109 | var jwt = try decode( 110 | allocator, 111 | struct { sub: []const u8 }, 112 | token, 113 | .{ .es256 = pair.public_key }, 114 | validation, 115 | ); 116 | defer jwt.deinit(); 117 | try std.testing.expectEqualStrings("test", jwt.claims.sub); 118 | } 119 | 120 | test "ES384.roundrip" { 121 | const allocator = std.testing.allocator; 122 | const validation: Validation = .{ 123 | .now = struct { 124 | fn func() u64 { 125 | return 1722441274; // Wednesday, July 31, 2024 3:54:34 PM - in seconds 126 | } 127 | }.func, 128 | }; 129 | 130 | // predicable key generation 131 | var seed: [48]u8 = undefined; 132 | _ = try std.fmt.hexToBytes(seed[0..], "8052030376d47112be7f73ed7a019293dd12ad910b654455798b4667d73de166"); 133 | const pair = try std.crypto.sign.ecdsa.EcdsaP384Sha384.KeyPair.create(seed); 134 | 135 | const token = try encode( 136 | allocator, 137 | .{ .alg = .ES384 }, 138 | .{ .sub = "test", .exp = validation.now() + 60 }, 139 | .{ .es384 = pair.secret_key }, 140 | ); 141 | defer allocator.free(token); 142 | //try std.testing.expectEqualStrings("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNzIyNDQxMzM0fQ.0ZiqWyJd3TKN2yB01Xhg91p8qmW-L0XrZunsHwkr2L3D79T45g8Imrqk5V5AhfLbBjqd2NPuZHcChpsSxiGtNw", token); 143 | 144 | var jwt = try decode( 145 | allocator, 146 | struct { sub: []const u8 }, 147 | token, 148 | .{ .es384 = pair.public_key }, 149 | validation, 150 | ); 151 | defer jwt.deinit(); 152 | try std.testing.expectEqualStrings("test", jwt.claims.sub); 153 | } 154 | 155 | test "EdDSA.roundtrip" { 156 | const allocator = std.testing.allocator; 157 | const validation: Validation = .{ 158 | .now = struct { 159 | fn func() u64 { 160 | return 1722441274; // Wednesday, July 31, 2024 3:54:34 PM - in seconds 161 | } 162 | }.func, 163 | }; 164 | 165 | // predicable key generation 166 | var seed: [32]u8 = undefined; 167 | _ = try std.fmt.hexToBytes(seed[0..], "8052030376d47112be7f73ed7a019293dd12ad910b654455798b4667d73de166"); 168 | const pair = try std.crypto.sign.Ed25519.KeyPair.create(seed); 169 | 170 | const token = try encode( 171 | allocator, 172 | .{ .alg = .EdDSA }, 173 | .{ .sub = "test", .exp = validation.now() + 60 }, 174 | .{ .edsa = pair.secret_key }, 175 | ); 176 | defer allocator.free(token); 177 | try std.testing.expectEqualStrings("eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNzIyNDQxMzM0fQ.qV1oOiw9DmKfaxVv3_W6zn878ke6D-G70bzAMTtNB4-3dCk5reLaqrXEMluP-0vjgfdQaJc-J0XANMP2CVymDQ", token); 178 | 179 | var jwt = try decode( 180 | allocator, 181 | struct { sub: []const u8 }, 182 | token, 183 | .{ .edsa = pair.public_key }, 184 | validation, 185 | ); 186 | defer jwt.deinit(); 187 | try std.testing.expectEqualStrings("test", jwt.claims.sub); 188 | } 189 | 190 | test "HS256.roundtrip" { 191 | const allocator = std.testing.allocator; 192 | const validation: Validation = .{ 193 | .now = struct { 194 | fn func() u64 { 195 | return 1722441274; // Wednesday, July 31, 2024 3:54:34 PM - in seconds 196 | } 197 | }.func, 198 | }; 199 | const token = try encode(allocator, .{ .alg = .HS256 }, .{ .sub = "test", .exp = validation.now() + 60 }, .{ .secret = "secret" }); 200 | defer allocator.free(token); 201 | var jwt = try decode( 202 | std.testing.allocator, 203 | struct { sub: []const u8 }, 204 | token, 205 | .{ .secret = "secret" }, 206 | validation, 207 | ); 208 | defer jwt.deinit(); 209 | try std.testing.expectEqualStrings("test", jwt.claims.sub); 210 | } 211 | 212 | test { 213 | std.testing.refAllDecls(@This()); 214 | } 215 | -------------------------------------------------------------------------------- /src/validation.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Algorithm = @import("root.zig").Algorithm; 3 | 4 | /// Validation rules for registered claims 5 | /// By default validation requires a `exp` claim to ensure the token has 6 | /// not expired. 7 | pub const Validation = struct { 8 | /// registered claims used for validation 9 | /// 10 | /// see also [rfc7519#section-4.1](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1) 11 | pub const RegisteredClaims = struct { 12 | exp: ?u64 = null, 13 | nbf: ?u64 = null, 14 | sub: ?[]const u8 = null, 15 | iss: ?[]const u8 = null, 16 | aud: ?[]const u8 = null, 17 | }; 18 | 19 | const RegisteredClaim = enum { exp, sub, iss, aud, nbf }; 20 | 21 | /// list of claims expected to have been provided 22 | required_claims: []const RegisteredClaim = &.{.exp}, 23 | /// amount of clockskew, in seconds, permitted 24 | leeway: u64 = 60, 25 | /// buffered amount of time to adjust timestamp to account for probably network transit time 26 | /// after which this token would be expired 27 | reject_tokens_expiring_in_less_than: u64 = 0, 28 | /// validate token is not past expiration time 29 | validate_exp: bool = true, 30 | /// validate token is not used not before expected time 31 | validate_nbf: bool = false, 32 | /// validate audience is as expected 33 | validate_aud: bool = true, 34 | /// validate expected audience 35 | aud: ?[]const []const u8 = null, 36 | /// validate expected issuer 37 | iss: ?[]const []const u8 = null, 38 | /// validate expected subject 39 | sub: ?[]const u8 = null, 40 | /// validate supported algoritm 41 | algorithms: []const Algorithm = &.{.HS256}, 42 | // returns "now" in seconds, relative to UTC 1970-01-01 43 | now: *const fn () u64 = struct { 44 | fn func() u64 { 45 | return @intCast(std.time.timestamp()); 46 | } 47 | }.func, 48 | 49 | /// validate that token meets baseline of registered claims rules 50 | pub fn validate(self: @This(), claims: RegisteredClaims) anyerror!void { 51 | // were all required registered claims provided? 52 | for (self.required_claims) |c| { 53 | switch (c) { 54 | .exp => if (claims.exp == null) return error.MissingExp, 55 | .sub => if (claims.sub == null) return error.MissingSub, 56 | .iss => if (claims.iss == null) return error.MissingIss, 57 | .aud => if (claims.aud == null) return error.MissingAud, 58 | .nbf => if (claims.nbf == null) return error.MissingNbf, 59 | } 60 | } 61 | 62 | // is this token being used before or after its intended window of usage? 63 | if (self.validate_exp or self.validate_nbf) { 64 | const nowSec = self.now(); 65 | if (self.validate_exp) { 66 | if (claims.exp) |exp| { 67 | if (exp - self.reject_tokens_expiring_in_less_than < nowSec - self.leeway) { 68 | return error.TokenExpired; 69 | } 70 | } 71 | } 72 | 73 | if (self.validate_nbf) { 74 | if (claims.nbf) |nbf| { 75 | if (nbf > nowSec - self.leeway) { 76 | return error.TokenEarly; 77 | } 78 | } 79 | } 80 | } 81 | 82 | // is this token intended for the expected subject? 83 | if (claims.sub) |actual| { 84 | if (self.sub) |expected| { 85 | if (!std.mem.eql(u8, actual, expected)) { 86 | return error.InvalidSubject; 87 | } 88 | } 89 | } 90 | 91 | // was this token issued by the expected party? 92 | if (claims.iss) |actual| { 93 | if (self.iss) |expected| { 94 | var found = false; 95 | for (expected) |exp| { 96 | if (std.mem.eql(u8, actual, exp)) { 97 | found = true; 98 | break; 99 | } 100 | } 101 | if (!found) { 102 | return error.InvalidIssuer; 103 | } 104 | } 105 | } 106 | 107 | // was this token intended for the expected audience? 108 | if (self.validate_aud) { 109 | if (claims.aud) |actual| { 110 | if (self.aud) |expected| { 111 | var found = false; 112 | for (expected) |exp| { 113 | if (std.mem.eql(u8, exp, actual)) { 114 | found = true; 115 | break; 116 | } 117 | } 118 | if (!found) { 119 | return error.InvalidAudience; 120 | } 121 | } 122 | } 123 | } 124 | } 125 | }; 126 | 127 | test Validation { 128 | for ([_]struct { 129 | desc: []const u8, 130 | claims: Validation.RegisteredClaims, 131 | validation: Validation, 132 | expect: ?anyerror, 133 | }{ 134 | .{ 135 | .desc = "default: missing exp", 136 | .claims = .{}, 137 | .validation = .{}, 138 | .expect = error.MissingExp, 139 | }, 140 | .{ 141 | .desc = "default: expired", 142 | .claims = .{ 143 | .exp = 0, 144 | }, 145 | .validation = .{}, 146 | .expect = error.TokenExpired, 147 | }, 148 | .{ 149 | .desc = "default: expected aud", 150 | .claims = .{ 151 | .exp = @intCast(std.time.timestamp() * 10), 152 | .aud = "foo", 153 | }, 154 | .validation = .{ 155 | .aud = &.{"bar"}, 156 | }, 157 | .expect = error.InvalidAudience, 158 | }, 159 | }) |case| { 160 | if (case.validation.validate(case.claims)) { 161 | std.testing.expect(case.expect != null) catch |err| { 162 | std.debug.print("error: {s}\n", .{case.desc}); 163 | return err; 164 | }; 165 | } else |err| { 166 | std.testing.expect(err == case.expect orelse return error.TestUnexpectedResult) catch |e| { 167 | std.debug.print("error: {s}", .{case.desc}); 168 | return e; 169 | }; 170 | } 171 | } 172 | } 173 | --------------------------------------------------------------------------------