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
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 akeyslist; each RSA key with akid, annand aneis kept; keys of other kinds, such as EC ones, are skipped, and a set with no usable key is error.JwksFetchFailed). Downloaded byinitand again when a token names an unknown key. No default.issuer: ?[]const u8 = null: Theissa 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 (audis 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 noAuthorization: Bearer <token>header.login_path: []const u8 = "/login": Where the middleware redirects a request that carries no token (unlessapi_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.publicis 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 becomesc.activeOrgId(), soorg_roleschecks 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 unknownkid. 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.roleschecks): 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_roleschecks). 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
pub const OrgClaims = enum
The shapes of organization claims JwksConfig.org_claims can read.
Values
phase_two: Keycloak with Phase Two organizations: anorganizationsclaim, {"<org id>": {"name": "..", "roles": ["..", ..}}; roles may also be a single string.clerk: Clerk: the session's active organization,o: {id, rol, slg}(v2 tokens) ororg_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
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 = nullname: ?[]const u8 = nulliss: ?[]const u8 = nullexp: 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
pub const JwksAuth = struct
The verifier: the provider's keys, kept in memory, and the middleware that uses them.
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 toinit.allocator: std.mem.Allocatorio: std.Iokeys: KeyMapkeys_lock: std.Io.RwLock = .initfetch_mutex: std.Io.Mutex = .initlast_fetch: ?std.Io.Timestamp = null
jwks.JwksAuth.init
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
pub fn deinit(self: *JwksAuth) void
Frees the cached keys.
jwks.JwksAuth.fetchJwks
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
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
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
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):
- In
api_modeall 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
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.