Login with a provider
Login is about an app that keeps its own accounts and passwords. This page is about leaving that to someone else: the visitor signs in at a provider's page, and the provider tells the app who they are. The app never sees a password.
Spider has three: Keycloak, a server you run, which keeps the accounts, the roles and the sign-in page; Google, which only says who someone is; and Clerk, a hosted service.
The code on this page is from examples/guide (source): servers kept as tests, for what the example apps do not use. The Keycloak tests run against a real Keycloak, started for the occasion.
Keycloak
spider g auth --provider=keycloak generates this in a project. What it comes down to:
var keycloak: spider.keycloak.Keycloak = undefined;
pub fn run() !void {
// (`io` is the one main() received; this package has no main.)
const io = std.Options.debug_io;
// An app reads these from the environment:
// `spider.keycloak.KeycloakConfig.fromEnv()`.
keycloak = try spider.keycloak.Keycloak.init(std.heap.smp_allocator, io, .{
.base_url = "http://127.0.0.1:8189",
.realm = "guide",
.client_id = "guide-app",
.client_secret = "only-for-the-tests",
// The address of the callback route below, as the realm knows it.
.redirect_uri = "http://127.0.0.1:3000/auth/callback",
.after_callback_path = "/me",
});
defer keycloak.deinit();
var server = spider.app(.{});
defer server.deinit();
try server
.use(spider.logger)
// Reads the token of each request and tells the routes who is there.
.use(keycloak.middleware())
.get("/auth/login", keycloak.loginHandler(), .{ .public = true })
.get("/auth/callback", keycloak.callbackHandler(), .{ .public = true })
.get("/auth/refresh", keycloak.refreshHandler(), .{ .public = true })
.post("/auth/logout", logout, .{ .public = true })
.get("/", home, .{ .public = true })
.get("/me", me, .{ .authenticated = true })
.get("/drafts", drafts, .{ .roles = &.{"editor"} })
.listen(.{});
}
Keycloak.initfetches the realm's signing keys, so Keycloak has to be reachable when the app starts.keycloak.middleware()reads the token of every request and tells the routes who is there. A route that is not.publicthen needs a signed-in visitor.- The three routes are the provider's own handlers, and they are
.public: nobody is signed in yet when they are called.
An app takes the settings from the environment, with spider.keycloak.KeycloakConfig.fromEnv(): KEYCLOAK_BASE_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET and KEYCLOAK_REDIRECT_URI. The redirect address is the app's callback route, written exactly as the client in Keycloak has it.
The round trip
The test plays the visitor, step by step:
/// Signs `username` in: the app sends the visitor to Keycloak, the visitor
/// types the password there, Keycloak sends them back with a code, and the
/// app trades the code for a session. Gives back the app's cookies.
fn signIn(arena: std.mem.Allocator, app: spider.testing.App, username: []const u8) ![]const u8 {
// 1. The app's login route: off to Keycloak, with a `state` the app
// will recognize on the way back (it keeps a copy in a cookie).
var login = try app.get("/auth/login");
defer login.deinit();
try login.expectStatus(302);
const at_keycloak = try arena.dupe(u8, login.header("Location").?);
try std.testing.expect(std.mem.startsWith(u8, at_keycloak, "http://127.0.0.1:8189/realms/guide/protocol/openid-connect/auth?"));
const state_cookie = try std.fmt.allocPrint(arena, "Cookie: __oauth_state={s}", .{login.cookie("__oauth_state").?});
// 2. Keycloak's own page, with the form.
const page = try browser(arena, "GET", at_keycloak, "", "");
const action_at = (std.mem.indexOf(u8, page, "action=\"") orelse return error.NoLoginForm) + "action=\"".len;
const action_raw = page[action_at..std.mem.indexOfScalarPos(u8, page, action_at, '"').?];
const action = try std.mem.replaceOwned(u8, arena, action_raw, "&", "&");
// 3. The visitor types the password, at Keycloak. The app never sees it.
const form = try std.fmt.allocPrint(arena, "username={s}&password=a+good+password", .{username});
const sent_back = try browser(arena, "POST", action, try header(arena, page, "Set-Cookie"), form);
const callback = try header(arena, sent_back, "Location");
try std.testing.expect(std.mem.startsWith(u8, callback, "http://127.0.0.1:3000/auth/callback?"));
// 4. Back at the app, with a code: the app trades it for tokens and
// sets its cookies.
var back = try app.with(&.{state_cookie}).get(callback["http://127.0.0.1:3000".len..]);
defer back.deinit();
try back.expectRedirect("/me");
return std.fmt.allocPrint(arena, "Cookie: __session={s}; __refresh={s}", .{ back.cookie("__session").?, back.cookie("__refresh").? });
}
test "login: a visitor is sent to sign in, and comes back as themselves" {
const app = try spider.testing.start(run);
var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Not signed in: a public page answers, any other sends to the login.
var open = try app.get("/");
defer open.deinit();
try open.expectStatus(200);
var closed = try app.get("/me");
defer closed.deinit();
try closed.expectRedirect("/auth/login");
const ana = app.with(&.{try signIn(arena, app, "ana")});
var me = try ana.get("/me");
defer me.deinit();
try me.expectStatus(200);
try me.expectContains("\"name\":\"Ana Ribeiro\"");
try me.expectContains("\"email\":\"ana@guide.example\"");
try me.expectContains("\"editor\":true");
}
After it, the browser holds two cookies of the app: __session, the token every request is checked by (7 days), and __refresh, which gets a new one when it runs out (30 days). Both are HttpOnly, and Secure unless the redirect address is plain http, as on a developer's machine.
A handler reads who is there the same way as with any login:
/// Who the token says is there.
fn me(c: *spider.Ctx) !spider.Response {
return c.json(.{
.id = c.userId() orelse "",
.name = c.params.get("_auth_name") orelse "",
.email = c.params.get("_auth_email") orelse "",
.editor = c.hasRole("editor"),
}, .{});
}
What happens without a token
- A browser asking for a page is sent to the login route.
- A token that ran out sends the browser to the refresh route, which gets a new one with the refresh cookie and comes back to the page that was asked for. A request htmx made, or a stream, gets a 401 there instead of a redirect.
- A program sends its token in an
Authorization: Bearerheader. One that is not valid is a 401:
test "login: a program sends its token in a header, and a bad one is refused" {
const app = try spider.testing.start(run);
var res = try app.with(&.{"Authorization: Bearer not-a-token"}).get("/me");
defer res.deinit();
try res.expectStatus(401);
}
With .api_mode = true in the config, every request without a valid token gets a 401 with a JSON body, and nothing is redirected: for an app that is only an API.
Roles
Roles are given to users in Keycloak, and a route asks for them with .roles:
test "login: the roles given in Keycloak are the roles the routes check" {
const app = try spider.testing.start(run);
var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Ana is an editor in the realm; Bo is not.
var ana = try app.with(&.{try signIn(arena, app, "ana")}).get("/drafts");
defer ana.deinit();
try ana.expectStatus(200);
var bo = try app.with(&.{try signIn(arena, app, "bo")}).get("/drafts");
defer bo.deinit();
try bo.expectStatus(403);
}
Keycloak does not put roles in the token the app keeps unless it is told to. The session cookie holds the ID token, and a new realm adds roles only to the access token: every .roles route then answers 403 to everyone. In the client's scopes, set the "realm roles" mapper to add the claim to the ID token. This is the realm the tests use, as Keycloak imports it; the mapper is under protocolMappers:
{
"realm": "guide",
"enabled": true,
"sslRequired": "none",
"roles": {
"realm": [
{
"name": "editor"
}
]
},
"clients": [
{
"clientId": "guide-app",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"secret": "only-for-the-tests",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://127.0.0.1:3000/auth/callback"
],
"protocolMappers": [
{
"name": "realm roles in the ID token",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"claim.name": "realm_access.roles",
"multivalued": "true",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
]
}
],
"users": [
{
"username": "ana",
"enabled": true,
"email": "ana@guide.example",
"emailVerified": true,
"firstName": "Ana",
"lastName": "Ribeiro",
"credentials": [
{
"type": "password",
"value": "a good password",
"temporary": false
}
],
"realmRoles": [
"editor"
]
},
{
"username": "bo",
"enabled": true,
"email": "bo@guide.example",
"emailVerified": true,
"firstName": "Bo",
"lastName": "Larsen",
"credentials": [
{
"type": "password",
"value": "a good password",
"temporary": false
}
]
}
]
}
The roles are read from realm_access.roles. For roles of one client instead of the realm, .roles_claim = "resource_access.<client>.roles".
A callback nobody asked for
The login route gives the browser a random state and keeps a copy in a cookie; the callback only goes on when the two match. A callback address made by hand, or sent to a visitor by someone else, has no matching cookie:
test "login: a callback nobody asked for is refused" {
const app = try spider.testing.start(run);
// A link someone made by hand: no cookie of this browser matches its state.
var res = try app.get("/auth/callback?state=made-up&code=made-up");
defer res.deinit();
try res.expectRedirect("/auth/login");
}
So every sign-in has to start at the login route, never at an address to Keycloak written by hand. For Keycloak's registration page, or to skip its form and go straight to a login it brokers, a route calls keycloak.authorize(c, .{ .endpoint = .registrations }) or .{ .idp_hint = "google" }.
Signing out
/// Signing out of the app is forgetting its two cookies.
fn logout(c: *spider.Ctx) !spider.Response {
return c.redirectWith("/", .{ .headers = &.{
.{ "Set-Cookie", try c.deleteCookie("__session", .{}) },
.{ "Set-Cookie", try c.deleteCookie("__refresh", .{}) },
} });
}
test "login: signing out forgets the app's cookies" {
const app = try spider.testing.start(run);
var res = try app.request(.{ .method = "POST", .target = "/auth/logout", .body = "" });
defer res.deinit();
try res.expectRedirect("/");
// Both come back empty and already expired.
try std.testing.expectEqualStrings("", res.cookie("__session").?);
try std.testing.expectEqualStrings("", res.cookie("__refresh").?);
}
That signs the visitor out of the app. They are still signed in at Keycloak, so the next visit to the login route brings them back without a password; to end that too, send them to the realm's own logout address.
"Sign in with Google" is smaller: there is no middleware and no cookie of Google's in the app. Google says who the visitor is, once, and the app signs them in the way it signs anyone in.
/// The OAuth client made in the Google Cloud console. An app reads the id
/// and the secret from the environment. (`pub var` so that the tests can
/// point the two calls to Google at servers of their own.)
pub var google: spider.google.GoogleConfig = .{
.client_id = "1234-abcd.apps.googleusercontent.com",
.client_secret = "only-for-the-tests",
.redirect_uri = "http://127.0.0.1:3000/auth/google/callback",
};
/// "Sign in with Google" links here.
fn googleLogin(c: *spider.Ctx) !spider.Response {
return spider.google.login(c, google);
}
/// Google sends the visitor back here.
fn googleCallback(c: *spider.Ctx) !spider.Response {
const profile = try spider.google.callback(c, google);
// Google said who this is, and that is all it does. The app now finds
// or makes its own account for `profile.id` and signs it in, the way
// it signs anyone in (see the Login page).
return c.json(.{ .google_id = profile.id, .email = profile.email, .name = profile.name }, .{});
}
The client's id and secret come from the Google Cloud console, where the callback address is registered too.
test "google: the visitor goes to Google with a state this browser keeps" {
const app = try spider.testing.start(server.run);
var res = try app.get("/auth/google");
defer res.deinit();
try res.expectStatus(302);
const to = res.header("Location").?;
try std.testing.expect(std.mem.startsWith(u8, to, "https://accounts.google.com/o/oauth2/v2/auth?"));
try std.testing.expect(std.mem.indexOf(u8, to, "client_id=1234-abcd.apps.googleusercontent.com") != null);
// The same value goes in the address and in a cookie.
const state = res.cookie(spider.google.state_cookie).?;
try std.testing.expect(std.mem.indexOf(u8, to, state) != null);
}
In the test, Google is played by two small servers: one trades the code for a token, the other gives the profile.
test "google: back with a code, the app asks Google who it is" {
const app = try spider.testing.start(server.run);
// Google, played by two servers of the test: one trades the code for a
// token, the other gives the profile for the token.
var token = try Stub.listen("200 OK", "{\"access_token\":\"ya29.token\"}");
var userinfo = try Stub.listen("200 OK", "{\"id\":\"108204\",\"email\":\"ana@gmail.example\",\"name\":\"Ana Ribeiro\",\"picture\":\"\"}");
const t1 = try std.Thread.spawn(.{}, Stub.serveOne, .{&token});
const t2 = try std.Thread.spawn(.{}, Stub.serveOne, .{&userinfo});
var buf1: [64]u8 = undefined;
var buf2: [64]u8 = undefined;
const before = server.google;
defer server.google = before;
server.google.token_endpoint = try std.fmt.bufPrint(&buf1, "http://127.0.0.1:{d}/token", .{token.port});
server.google.userinfo_endpoint = try std.fmt.bufPrint(&buf2, "http://127.0.0.1:{d}/userinfo", .{userinfo.port});
// The visitor starts at the app...
var start = try app.get("/auth/google");
defer start.deinit();
var cookie_buf: [128]u8 = undefined;
var target_buf: [160]u8 = undefined;
const state = start.cookie(spider.google.state_cookie).?;
const cookie = try std.fmt.bufPrint(&cookie_buf, "Cookie: {s}={s}", .{ spider.google.state_cookie, state });
// ...and Google sends them back with the same state and a code.
var back = try app.with(&.{cookie}).get(try std.fmt.bufPrint(&target_buf, "/auth/google/callback?state={s}&code=4/0AbCdEf", .{state}));
defer back.deinit();
t1.join();
t2.join();
try back.expectStatus(200);
try back.expectContains("\"google_id\":\"108204\"");
try back.expectContains("\"email\":\"ana@gmail.example\"");
// The code and the client's secret went to Google, from the server.
try std.testing.expect(token.got("code=4%2F0AbCdEf") or token.got("code=4/0AbCdEf"));
try std.testing.expect(token.got("client_secret=only-for-the-tests"));
try std.testing.expect(userinfo.got("Authorization: Bearer ya29.token"));
}
A profile has id (Google's own, and the one to store: an e-mail can change), email, name and picture. With it the app finds or makes its account and starts its session: with accounts generated by spider g auth, that is spider.session.start, as Login shows.
The callback fails by name: error.OAuthStateMismatch when the sign-in was not started in this browser, error.OAuthCodeMissing when the visitor said no at Google's page, and two more for a code Google refuses and for a profile it does not give. The first two are a 400:
test "google: a callback this browser did not ask for is refused before Google is asked anything" {
const app = try spider.testing.start(server.run);
var res = try app.get("/auth/google/callback?state=made-up&code=4/0AbCdEf");
defer res.deinit();
try res.expectStatus(400);
}
Clerk
spider.clerk has the shape of the Keycloak one: a value made at start, a middleware, a login route and a callback route.
It was not run for this page, because it needs a Clerk account: what follows is from its reference.
Clerk.init(allocator, io, .{ .publishable_key, .secret_key, .redirect_uri }). Nothing is read from the environment. The address of the instance is taken from the publishable key, and its signing keys are fetched at start.clerk.middleware(),clerk.loginHandler()andclerk.callbackHandler(), mounted as above. The callback sets the__sessioncookie.- There is no refresh route: a token that ran out is a 401.
- Clerk tokens carry no roles unless a custom session claim adds them:
{"roles": "{{user.public_metadata.roles}}"}in Clerk, and.roles_claim = "roles"in the config. The role in the active organization, which.org_roleschecks, is read without any setting.
Running the Keycloak tests
They need Docker. A script starts a Keycloak with the test realm, runs the tests and removes it:
#!/usr/bin/env bash
# Runs the login examples (login/tests.zig) against a Keycloak that exists
# for this run: it is started from login/docker-compose.yml with the realm
# of login/realm.json, and removed when the tests end, whether they pass
# or not. Keycloak takes a minute or two to start.
#
# ./login-test.sh
set -euo pipefail
cd "$(dirname "$0")"
compose() { docker compose -f login/docker-compose.yml -p guide-login "$@"; }
trap 'compose down --volumes >/dev/null 2>&1' EXIT
compose up -d >/dev/null
echo "waiting for Keycloak..."
for _ in $(seq 1 120); do
curl -fsS -o /dev/null http://127.0.0.1:8189/realms/guide/.well-known/openid-configuration 2>/dev/null && break
sleep 2
done
zig build test-login "$@"
# A Keycloak that exists for one run of the login tests: its data is in
# memory and it is removed when they end (see ../login-test.sh).
services:
keycloak:
image: quay.io/keycloak/keycloak:26.2
command: start-dev --import-realm
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
volumes:
- ./realm.json:/opt/keycloak/data/import/realm.json:ro
ports:
- "127.0.0.1:8189:8080"