Skip to content

4. An API key

Reads stay open; creating, changing and deleting need Authorization: Bearer <API_KEY>.

The setting and the guard

A settings struct reads API_KEY from the environment when the module that lists it mounts. dev_defaults apply only when KZIG_ENV is development or test, so production can't start without a real key. The guard is middleware with a service's fields: the settings are injected.

src/guards/api-key.guard.zig
const std = @import("std");
const kzig = @import("kzig");

const Sha256 = std.crypto.hash.sha2.Sha256;

/// `API_KEY`: required in production; development and test get a default.
pub const AuthSettings = struct {
    pub const dev_defaults = .{ .api_key = "dev-api-key-change-me-0123456789ab" };
    api_key: []const u8,

    pub fn validate(self: *const AuthSettings, issues: *kzig.Issues) !void {
        if (self.api_key.len < 32) try issues.add("api_key", "must be at least 32 characters");
    }
};

/// `Authorization: Bearer <API_KEY>` or 401. The key is compared as a digest,
/// so the check takes the same time whatever was sent.
pub const ApiKeyGuard = struct {
    settings: *const AuthSettings,

    pub fn handle(self: *ApiKeyGuard, ctx: *kzig.Ctx, next: *kzig.Next) !void {
        const header = ctx.header("authorization") orelse "";
        const key = if (std.ascii.startsWithIgnoreCase(header, "Bearer ")) std.mem.trim(u8, header["Bearer ".len..], " ") else "";
        if (!std.crypto.timing_safe.eql([Sha256.digest_length]u8, digest(key), digest(self.settings.api_key)))
            return ctx.fail(.unauthorized, "Invalid API key");
        next.run();
    }
};

fn digest(s: []const u8) [Sha256.digest_length]u8 {
    var out: [Sha256.digest_length]u8 = undefined;
    Sha256.hash(s, &out, .{});
    return out;
}

List AuthSettings in the module beside DbSettings:

.settings = &.{ database.DbSettings, AuthSettings },

On the routes that write

Middleware runs on the App, a controller, or a single route. Here it's the three writes:

src/modules/bookmarks/bookmark.controller.zig
const kzig = @import("kzig");
const repo = @import("bookmark.repository.zig");
const constants = @import("bookmark.constants.zig");
const BookmarkService = @import("bookmark.service.zig").BookmarkService;

const ApiKeyGuard = @import("../../guards/api-key.guard.zig").ApiKeyGuard;

const Id = struct { id: i32 };

pub const BookmarkController = struct {
    service: *BookmarkService,

    pub const docs = .{ .tags = &[_][]const u8{"Bookmarks"} };

    /// Writes need `Authorization: Bearer <API_KEY>`; reads are open.
    const key = .{ApiKeyGuard};

    pub const routes = .{
        .{ .method = .GET, .path = "/", .handler = "list", .docs = .{
            .summary = "List bookmarks",
            .responses = .{
                .{ .status = 200, .description = "A page of bookmarks", .body = kzig.Paginated(repo.Bookmark) },
            },
        } },
        .{ .method = .GET, .path = "/:id", .handler = "get", .docs = .{
            .summary = "Get a bookmark",
            .responses = .{ .{ .status = 200, .description = "The bookmark", .body = repo.Bookmark }, not_found },
        } },
        .{ .method = .POST, .path = "/", .handler = "create", .middleware = key, .docs = .{
            .summary = "Create a bookmark",
            .bearer_auth = true,
            .responses = .{
                .{ .status = 201, .description = "Created", .body = repo.Bookmark },
                .{ .status = 401, .description = "No or wrong API key" },
                .{ .status = 409, .description = "This URL is saved already" },
            },
        } },
        .{ .method = .PATCH, .path = "/:id", .handler = "update", .middleware = key, .docs = .{
            .summary = "Update a bookmark",
            .bearer_auth = true,
            .responses = .{ .{ .status = 200, .description = "The updated bookmark", .body = repo.Bookmark }, not_found },
        } },
        .{ .method = .DELETE, .path = "/:id", .handler = "remove", .middleware = key, .docs = .{
            .summary = "Delete a bookmark",
            .bearer_auth = true,
            .responses = .{ .{ .status = 204, .description = "Deleted" }, not_found },
        } },
    };
    const not_found = .{ .status = 404, .description = "No bookmark with this id" };

    pub fn list(self: *BookmarkController, ctx: *kzig.Ctx, got: struct { query: constants.Query }) !kzig.Paginated(repo.Bookmark) {
        const page = try self.service.list(ctx.arena, got.query);
        return got.query.paginate(ctx, repo.Bookmark, page.rows, page.total);
    }

    pub fn get(self: *BookmarkController, ctx: *kzig.Ctx, got: struct { params: Id }) !repo.Bookmark {
        return self.service.get(ctx.arena, got.params.id);
    }

    pub fn create(self: *BookmarkController, ctx: *kzig.Ctx, got: struct { body: repo.CreateBookmark }) !void {
        try ctx.created(try self.service.create(ctx.arena, got.body));
    }

    pub fn update(self: *BookmarkController, ctx: *kzig.Ctx, got: struct { params: Id, body: repo.UpdateBookmark }) !repo.Bookmark {
        return self.service.update(ctx.arena, got.params.id, got.body);
    }

    /// 204 when it returns.
    pub fn remove(self: *BookmarkController, ctx: *kzig.Ctx, got: struct { params: Id }) !void {
        try self.service.remove(ctx.arena, got.params.id);
    }
};
$ curl -H 'content-type: application/json' -d '{"url":"https://x.dev","title":"x"}' localhost:3000/api/v1/bookmarks
{"type":"about:blank","title":"Unauthorized","status":401,"detail":"Invalid API key"}
$ curl -H 'authorization: Bearer dev-api-key-change-me-0123456789ab' -H 'content-type: application/json' \
    -d '{"url":"https://x.dev","title":"x"}' localhost:3000/api/v1/bookmarks
{"id":2,"url":"https://x.dev","title":"x","notes":null,"createdAt":"…"}

Without KZIG_ENV=development there's no default key, and the app says so before it listens:

$ zig build run
error: Config is invalid:
error:   API_KEY: required

Next, API docs.