keycloak
On this page:
- spider.keycloak: Authentication through a Keycloak realm: the login, callback and refresh handlers, and the middleware that checks the token.
spider.keycloak
Keycloak login (spider.keycloak): the OAuth authorization-code flow against a realm, plus the middleware that checks the token on every request. The token check itself is spider.jwks; this file adds the login, callback and refresh routes and the cookies they set.
spider.keycloak.KeycloakConfig
pub const KeycloakConfig = struct
What Keycloak.init takes. fromEnv() fills the connection settings from the environment; set the other fields on the result.
Fields
base_url: []const u8: Address of the Keycloak server, without a trailing slash and without/realms/...(the issuer is{base_url}/realms/{realm}).fromEnv:KEYCLOAK_BASE_URL. No default.realm: []const u8: The realm users log in to.fromEnv:KEYCLOAK_REALM. No default.client_id: []const u8: The app's client in that realm.fromEnv:KEYCLOAK_CLIENT_ID. No default.client_secret: []const u8: The client's secret, sent when a code or a refresh token is exchanged for tokens.fromEnv:KEYCLOAK_CLIENT_SECRET. No default.redirect_uri: []const u8 = "http://localhost:3000/auth/callback": The full URL of the app's callback route, as registered in the client (the routecallbackHandler()answers).fromEnv:KEYCLOAK_REDIRECT_URI.login_path: []const u8 = "/auth/login": Where a browser request without a token is redirected, and where a rejected callback or a failed refresh ends. MountloginHandler()there.after_callback_path: []const u8 = "/": Where the callback redirects once the cookies are set, and where a refresh goes when it has no usable?next=.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). The login, callback and refresh routes must be listed, or be.public. Default: none.refresh_cookie_name: []const u8 = "__refresh": Cookie that keeps the refresh token (HttpOnly, SameSite=Lax, 30 days; Secure unlessredirect_uriis plain http, like every cookie set here: a browser drops a Secure cookie that arrives over http).refresh_path: []const u8 = "/auth/refresh": Where a browser request with an expired token is redirected, as{refresh_path}?next=<the request's target>. MountrefreshHandler()there. htmx and SSE requests get 401 instead of the redirect.api_mode: bool = false: true: a request without a valid token gets a 401 with a JSON body instead of a redirect.active_org_cookie: ?[]const u8 = null: See JwksConfig.active_org_cookie.verify_state: bool = true: Verify the OAuthstateon the callback against a nonce cookie set byauthorize()(login CSRF protection). Every flow that sends users to Keycloak must go throughauthorize()/loginHandler()for this to pass — a hand-built authorize URL has no matching cookie and its callback is rejected (redirected back tologin_path).state_cookie_name: []const u8 = "__oauth_state": Cookie that keeps the nonce ofverify_state(HttpOnly, SameSite=Lax, 10 minutes).audience: ?[]const u8 = null: Client whose tokens are accepted (see JwksConfig.audience); defaults toclient_id, so a token another client of the realm obtained for the user (admin-cli, a partner app) is rejected.roles_claim: ?[]const u8 = "realm_access.roles": See JwksConfig.roles_claim(e.g. "resource_access.<client>.roles" for client roles instead of realm roles).org_claims: jwks.OrgClaims = .phase_two: See JwksConfig.org_claims.map_claims: ?*const fn (c: *Ctx, claims: std.json.ObjectMap) anyerror!void = null: See JwksConfig.map_claims.
keycloak.KeycloakConfig.fromEnv
pub fn fromEnv() KeycloakConfig
The connection settings from the environment — KEYCLOAK_BASE_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET and KEYCLOAK_REDIRECT_URI (default http://localhost:3000/auth/callback); every other field keeps its default. One of the first four that is not set becomes an empty string, with no error here: Keycloak.init is what fails then. Adjust the result as needed:
var cfg = spider.keycloak.KeycloakConfig.fromEnv();
cfg.after_callback_path = "/auth/session";
spider.keycloak.AuthorizeEndpoint
pub const AuthorizeEndpoint = enum
Which Keycloak page Keycloak.authorize sends the user to.
Values
auth: Regular login page.registrations: Keycloak's self-registration page.
spider.keycloak.AuthorizeOptions
pub const AuthorizeOptions = struct
Options of Keycloak.authorize.
Fields
endpoint: AuthorizeEndpoint = .auth: The login page (default) or the registration page.payload: []const u8 = "": App data carried through the OAuth round-trip insidestate. The callback understands "invite:<token>" and forwards it asafter_callback_path?invite=<token>.idp_hint: ?[]const u8 = null: Sent askc_idp_hint(e.g. "google") to skip Keycloak's own login form.
spider.keycloak.Keycloak
pub const Keycloak = struct
One Keycloak realm for the app: create it once at startup, add its middleware and mount its three handlers.
var kc = try spider.keycloak.Keycloak.init(allocator, io, spider.keycloak.KeycloakConfig.fromEnv());
defer kc.deinit();
server
.use(kc.middleware())
.get("/auth/login", kc.loginHandler(), .{ .public = true })
.get("/auth/callback", kc.callbackHandler(), .{ .public = true })
.get("/auth/refresh", kc.refreshHandler(), .{ .public = true });
The middleware and the handlers keep a pointer to this value: it must not move or be freed while the server runs. They also keep it in one static slot each, so a process can use one Keycloak instance; a second one replaces the first in every handler.
Fields
jwks: JwksAuth: The token verifier (spider.jwks.JwksAuth) built from the config.config: KeycloakConfig: The config given toinit. Its strings are not copied.issuer: []const u8:{base_url}/realms/{realm}: theissevery token must carry.jwks_url: []const u8: Owned here because JwksAuth keeps referencing it (re-fetch on an unknownkid), so it must live as long as the Keycloak instance.allocator: std.mem.Allocator
keycloak.Keycloak.init
pub fn init(allocator: std.mem.Allocator, io: std.Io, config: KeycloakConfig) !Keycloak
Builds the issuer and downloads the realm's signing keys, so Keycloak must be reachable when the app starts: error.JwksFetchFailed when it does not answer with a key set, or the HTTP client's error when it cannot be reached. config's strings are not copied: they must stay valid as long as the result.
keycloak.Keycloak.deinit
pub fn deinit(self: *Keycloak) void
Frees the keys and the two URLs init allocated.
keycloak.Keycloak.middleware
pub fn middleware(self: *Keycloak) MiddlewareFn
The middleware that checks the token of every request (see JwksAuth.middleware): server.use(kc.middleware()).
keycloak.Keycloak.authUrl
pub fn authUrl(self: *const Keycloak, state: []const u8) ![]u8
The login page URL with state (URL-encoded, like the client id and the redirect address; no state cookie is set). Allocated with the allocator given to init; the caller frees it. Prefer authorize: with verify_state on (the default), the callback of a login started from this URL is rejected.
keycloak.Keycloak.loginHandler
pub fn loginHandler(self: *Keycloak) Handler
The handler for the login route: redirects to Keycloak's login page (authorize with the default options).
keycloak.Keycloak.callbackHandler
pub fn callbackHandler(self: *Keycloak) Handler
The handler for the route named by redirect_uri. Checks state (when it fails: redirect to login_path), exchanges code for tokens, sets the __session cookie (the ID token, or the access token when there is none; 7 days) and the refresh cookie, then redirects to after_callback_path (with ?invite=<token> for an "invite:<token>" payload). 400 without code; 502 when Keycloak returns no token. When Keycloak cannot be reached or its answer is not JSON, the handler returns that error (a 500 unless the app's onError says otherwise).
keycloak.Keycloak.refreshHandler
pub fn refreshHandler(self: *Keycloak) Handler
The handler for refresh_path: trades the refresh cookie for new tokens, sets the cookies again and redirects to ?next= when that is a local path, else to after_callback_path. Without a refresh cookie, or when Keycloak gives no token, it clears both cookies and redirects to login_path. When Keycloak cannot be reached, the handler returns the HTTP client's error.
keycloak.Keycloak.authorize
pub fn authorize(self: *Keycloak, c: *Ctx, opts: AuthorizeOptions) !Response
Redirects the user to Keycloak with a fresh state whose nonce is also stored in a short-lived HttpOnly cookie, so callbackHandler() can tell its own round-trips from forged ones. Use this for every login, registration or IdP-hinted flow instead of hand-building the URL. The answer is a 302.