Zig 0.17.0

jwks

On this page:

  • spider.jwks: Checks tokens signed by an identity provider, with the keys it publishes (JWKS).

spider.jwks

Token login for any OpenID Connect provider (spider.jwks): checks the RS256 signature of a JWT against the provider's published keys (its JWKS) and puts the user, roles and organizations of the token on the request. spider.keycloak and spider.clerk are built on it.

spider.jwks.JwksConfig

zig
pub const JwksConfig = struct

What JwksAuth.init takes. None of its strings is copied: they must stay valid as long as the JwksAuth.

Fields

  • jwks_url: []const u8: URL of the provider's key set (a JSON object with a keys list; each RSA key with a kid, an n and an e is kept; keys of other kinds, such as EC ones, are skipped, and a set with no usable key is error.JwksFetchFailed). Downloaded by init and again when a token names an unknown key. No default.
  • issuer: ?[]const u8 = null: The iss a token must carry: without the claim the token fails with error.MissingIssuer, with another value error.InvalidIssuer. null (the default) skips the check.
  • audience: ?[]const u8 = null: The client this app accepts tokens for. A token passes when it was issued to it (azp) or is addressed to it (aud is it, or a list containing it); anything else — e.g. a token some other client of the same realm obtained for the user — fails with error.InvalidAudience. null skips the check (then any client of the issuer can log users in).
  • cookie_name: []const u8 = "__session": Cookie the middleware reads the token from when the request has no Authorization: Bearer <token> header.
  • login_path: []const u8 = "/login": Where the middleware redirects a request that carries no token (unless api_mode).
  • after_callback_path: []const u8 = "/": Not read by JwksAuth; the providers built on it have their own.
  • auth_skip_paths: []const []const u8 = &.{}: Paths the middleware lets through without a token: the path itself and anything under it ("/auth" also covers /auth/login, not /authors). A route marked .public is let through too. Default: none.
  • refresh_path: ?[]const u8 = null: Where a browser request with an expired token is redirected, as {refresh_path}?next=<the request's target>; htmx and SSE requests get 401 instead. null (the default): an expired token is a 401.
  • api_mode: bool = false: true: a missing, invalid or expired token is answered with a 401 and a JSON body ({"error":"unauthorized","message":...}), never a redirect.
  • active_org_cookie: ?[]const u8 = null: Cookie holding the org the user picked (e.g. "orbitx_condo"). When present and naming an org the user belongs to, its value becomes c.activeOrgId(), so org_roles checks only count roles held in that org. A cookie for any other org is ignored (same as no cookie).
  • min_refetch_interval_ms: u64 = 30_000: Minimum time between JWKS re-fetches triggered by tokens with an unknown kid. Bounds how often arbitrary requests can make us call the IdP (a forged kid would otherwise force one fetch per request). A real key rotation is still picked up on the first unknown kid after it.
  • roles_claim: ?[]const u8 = "realm_access.roles": Where the token carries the user's roles (what .roles checks): a claim name, or a dotted path into nested objects; the claim is a list of strings or one string. A name containing dots is first looked up whole. Defaults to Keycloak's realm roles. Others:
  • org_claims: OrgClaims = .phase_two: How the token carries organization memberships (what .org_roles checks). See OrgClaims.
  • map_claims: ?*const fn (c: *Ctx, claims: std.json.ObjectMap) anyerror!void = null: Called with the verified token's claims after the mapping above, to put anything else on the request with c.addRole / c.addOrgRole / c.setActiveOrg (permissions, groups, a tenant id...). Like Spring's JwtAuthenticationConverter. An error it returns is the request's (error.Forbidden: 403).

spider.jwks.OrgClaims

zig
pub const OrgClaims = enum

The shapes of organization claims JwksConfig.org_claims can read.

Values

  • phase_two: Keycloak with Phase Two organizations: an organizations claim, {"<org id>": {"name": "..", "roles": ["..", ..}}; roles may also be a single string.
  • clerk: Clerk: the session's active organization, o: {id, rol, slg} (v2 tokens) or org_id / org_role / org_slug (v1; the "org:" prefix of the role is dropped, so both give e.g. "admin"). It is also the active org (c.activeOrgId()).
  • none: The token carries no organizations.

spider.jwks.Claims

zig
pub const Claims = struct

What JwksAuth.verifyToken returns: the standard claims of a verified token. The strings are copies allocated with the allocator given to it.

Fields

  • sub: []const u8: The user id at the provider.
  • email: ?[]const u8 = null
  • name: ?[]const u8 = null
  • iss: ?[]const u8 = null
  • exp: i64: Expiry, in seconds since the epoch. verifyToken does not compare it.
  • nbf: ?i64 = null: Not valid before, in seconds since the epoch, when the token has it.
  • realm_access: ?RealmAccess = null: Keycloak's realm roles (realm_access.roles), when present.
  • extra: std.StringHashMapUnmanaged([]const u8) = .{}: Not filled by anything today: always empty.

spider.jwks.JwksAuth

zig
pub const JwksAuth = struct

The verifier: the provider's keys, kept in memory, and the middleware that uses them.

zig
var auth = try spider.jwks.JwksAuth.init(allocator, io, .{
    .jwks_url = "https://idp.example.com/.well-known/jwks.json",
    .issuer = "https://idp.example.com",
    .audience = "my-app",
});
defer auth.deinit();
server.use(auth.middleware());

The middleware keeps a pointer to this value: it must not move or be freed while the server runs.

Fields

  • config: JwksConfig: The config given to init.
  • allocator: std.mem.Allocator
  • io: std.Io
  • keys: KeyMap
  • keys_lock: std.Io.RwLock = .init
  • fetch_mutex: std.Io.Mutex = .init
  • last_fetch: ?std.Io.Timestamp = null

jwks.JwksAuth.init

zig
pub fn init(allocator: std.mem.Allocator, io: std.Io, config: JwksConfig) !JwksAuth

Downloads the key set, so the provider must be reachable when the app starts: error.JwksFetchFailed when the answer is not a 200 with a key set, or the HTTP client's error when it cannot be reached.

jwks.JwksAuth.deinit

zig
pub fn deinit(self: *JwksAuth) void

Frees the cached keys.

jwks.JwksAuth.fetchJwks

zig
pub fn fetchJwks(self: *JwksAuth) !void

Re-downloads the key set and replaces the cached one. Safe to call concurrently with verification. Fails like init; the cached keys are then kept.

jwks.JwksAuth.verifyToken

zig
pub fn verifyToken(self: *JwksAuth, allocator: std.mem.Allocator, token: []const u8) !Claims

The claims of token once its signature (RSA PKCS#1 v1.5 with SHA-256), issuer and audience are checked. It does NOT check exp or nbf: compare them yourself (the middleware does). allocator should be an arena. Errors: InvalidToken (not three parts), UnsupportedAlgorithm (the header's alg is not "RS256"), UnknownKey (no key for the token's kid, even after a re-fetch, or the re-fetch was skipped because the last one was under min_refetch_interval_ms ago), InvalidSignature, UnsupportedKeySize (the signature is not 1024, 2048, 3072 or 4096 bits), MissingIssuer, InvalidIssuer, InvalidAudience. A token malformed in another way fails with the error of the base64, JSON or RSA code (error.MissingField when the payload has no sub or no exp).

jwks.JwksAuth.verifyTokenIo

zig
pub fn verifyTokenIo(self: *JwksAuth, io: std.Io, allocator: std.mem.Allocator, token: []const u8) !Claims

Same as verifyToken, using io (the request's) for locking and any JWKS re-fetch. allocator should be an arena: key copies live in it.

jwks.JwksAuth.middleware

zig
pub fn middleware(self: *JwksAuth) MiddlewareFn

The middleware: server.use(auth.middleware()). It takes the token from Authorization: Bearer or the cookie, verifies it, checks exp and nbf, and sets the request's user (c.userId() is the token's sub), roles and organizations, which is what .authenticated, .roles and .org_roles on a route check. Routes marked .public, auth_skip_paths and a path no route matches (it gets its 404) pass untouched.

Without a token: redirect (302) to login_path. With one that does not verify: 401 with the error's name as the body. Expired: see refresh_path. Not valid yet (nbf):

  1. In api_mode all four are a JSON 401.

The instance is kept in one static slot: a process can use the middleware of one JwksAuth; a second call replaces the first.

spider.jwks.applyClaims

zig
pub fn applyClaims(c: *Ctx, config: JwksConfig, claims: std.json.ObjectMap) !void

Puts the token's roles and organizations on the request, as configured (roles_claim, org_claims), then runs map_claims.