1
Fork 0
blog-zig-postgres/extras/auth-clerk/clerk.zig
Leonardo Devai 3a72aad99d Review and modernize all 42 projects to the updated standard
Pinned 2026 toolchains (Go 1.26, Rust 1.98/edition 2024, Python 3.14 + uv, Node 24,
Zig 0.16, NixOS 26.05), postgres 18 / mongo 8, lockfiles built from, non-root
runtimes, .dockerignore, per-project LICENSE, READMEs with the git.devai.io clone
line, checkout@v7 CI. Security fixes in the legacy Rust APIs (any-password login,
self-assigned admin, hard-coded JWT secret), JWT alg/exp/sub enforcement across the
blog series, safe markdown links in the frontends, and many smaller bugs — every
project was built, run and exercised end to end.

Adds scripts/publish.sh + a CI publish job that splits every folder into its own
repo at git.devai.io/templates/<folder>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
2026-09-27 21:10:38 +02:00

146 lines
6 KiB
Zig

//! Drop-in Clerk session-token verifier: checks RS256 JWTs against your
//! Clerk instance's JWKS using only std. See the README next to this file
//! for how to wire it in.
const std = @import("std");
const Allocator = std.mem.Allocator;
const rsa = std.crypto.Certificate.rsa;
const Sha256 = std.crypto.hash.sha2.Sha256;
const b64 = std.base64.url_safe_no_pad;
/// Tolerated clock skew between Clerk and this server, in seconds.
const leeway = 5;
pub const Verifier = struct {
gpa: Allocator,
io: std.Io,
/// e.g. https://your-instance.clerk.accounts.dev/.well-known/jwks.json
jwks_url: []const u8,
mutex: std.Io.Mutex = .init,
keys_arena: std.heap.ArenaAllocator,
keys: []const Jwk = &.{},
fetched_at: i64 = 0,
const Jwk = struct { kid: []const u8, n: []const u8, e: []const u8 };
pub fn init(gpa: Allocator, io: std.Io, jwks_url: []const u8) Verifier {
return .{ .gpa = gpa, .io = io, .jwks_url = jwks_url, .keys_arena = .init(gpa) };
}
pub fn deinit(self: *Verifier) void {
self.keys_arena.deinit();
}
/// Checks the signature, `exp` and `nbf`; returns the Clerk user id
/// (`sub`, e.g. "user_2f8a...") allocated in `arena`. Thread-safe.
pub fn verify(self: *Verifier, arena: Allocator, token: []const u8) ![]const u8 {
var parts = std.mem.splitScalar(u8, token, '.');
const header_b64 = parts.next() orelse return error.InvalidToken;
const payload_b64 = parts.next() orelse return error.InvalidToken;
const sig_b64 = parts.next() orelse return error.InvalidToken;
if (parts.next() != null) return error.InvalidToken;
const header = try parseSegment(struct { alg: []const u8, kid: []const u8 }, arena, header_b64);
if (!std.mem.eql(u8, header.alg, "RS256")) return error.InvalidToken;
const jwk = (try self.findKey(arena, header.kid)) orelse blk: {
// Unknown kid: Clerk may have rotated its keys.
try self.refresh();
break :blk (try self.findKey(arena, header.kid)) orelse return error.UnknownKey;
};
try verifyRs256(arena, jwk, token[0 .. header_b64.len + 1 + payload_b64.len], sig_b64);
const claims = try parseSegment(struct {
sub: []const u8,
exp: i64,
nbf: i64 = 0,
}, arena, payload_b64);
const now = std.Io.Clock.now(.real, self.io).toSeconds();
if (claims.exp + leeway <= now or claims.nbf - leeway > now) return error.TokenExpired;
return claims.sub;
}
fn findKey(self: *Verifier, arena: Allocator, kid: []const u8) !?Jwk {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
for (self.keys) |key| {
if (!std.mem.eql(u8, key.kid, kid)) continue;
// Copy out so a concurrent refresh can't free it under us.
return .{
.kid = try arena.dupe(u8, key.kid),
.n = try arena.dupe(u8, key.n),
.e = try arena.dupe(u8, key.e),
};
}
return null;
}
/// Refetches the JWKS, at most once a minute so tokens with made-up kids
/// can't turn every request into a call to Clerk.
fn refresh(self: *Verifier) !void {
const now = std.Io.Clock.now(.real, self.io).toSeconds();
{
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
if (now - self.fetched_at < 60) return;
self.fetched_at = now;
}
var body: std.Io.Writer.Allocating = .init(self.gpa);
defer body.deinit();
var client: std.http.Client = .{ .allocator = self.gpa, .io = self.io };
defer client.deinit();
const result = client.fetch(.{
.location = .{ .url = self.jwks_url },
.response_writer = &body.writer,
}) catch return error.JwksFetchFailed;
if (result.status != .ok) return error.JwksFetchFailed;
var arena: std.heap.ArenaAllocator = .init(self.gpa);
errdefer arena.deinit();
const jwks = std.json.parseFromSliceLeaky(struct {
keys: []const struct { kty: []const u8 = "", kid: []const u8 = "", n: []const u8 = "", e: []const u8 = "" },
}, arena.allocator(), body.written(), .{ .ignore_unknown_fields = true }) catch
return error.JwksInvalid;
var keys: std.ArrayList(Jwk) = .empty;
for (jwks.keys) |key| {
if (!std.mem.eql(u8, key.kty, "RSA") or key.kid.len == 0) continue;
try keys.append(arena.allocator(), .{ .kid = key.kid, .n = key.n, .e = key.e });
}
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
self.keys_arena.deinit();
self.keys_arena = arena;
self.keys = keys.items;
}
};
fn verifyRs256(arena: Allocator, jwk: Verifier.Jwk, signing_input: []const u8, sig_b64: []const u8) !void {
var modulus = try decode(arena, jwk.n);
while (modulus.len > 0 and modulus[0] == 0) modulus = modulus[1..];
const exponent = try decode(arena, jwk.e);
const sig = try decode(arena, sig_b64);
if (sig.len != modulus.len) return error.InvalidSignature;
const key = rsa.PublicKey.fromBytes(exponent, modulus) catch return error.InvalidKey;
switch (modulus.len) {
inline 256, 384, 512 => |len| rsa.PKCS1v1_5Signature.verify(len, sig[0..len].*, signing_input, key, Sha256) catch
return error.InvalidSignature,
else => return error.UnsupportedKeySize,
}
}
/// Decodes one base64url token segment and parses its JSON into `T`.
fn parseSegment(comptime T: type, arena: Allocator, segment: []const u8) !T {
return std.json.parseFromSliceLeaky(T, arena, try decode(arena, segment), .{
.ignore_unknown_fields = true,
}) catch error.InvalidToken;
}
fn decode(arena: Allocator, s: []const u8) ![]u8 {
const len = b64.Decoder.calcSizeForSlice(s) catch return error.InvalidToken;
const out = try arena.alloc(u8, len);
b64.Decoder.decode(out, s) catch return error.InvalidToken;
return out;
}