Quarkus
This guide adds Bunkhouse to a server-rendered Quarkus application: Quarkus REST endpoints, Qute templates and Quarkus Security. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.
Tested setup
Section titled “Tested setup”| Framework | Quarkus 3.40.1 (platform BOM): Quarkus REST with Jackson, Qute, form authentication |
| Runtime | Java 25 (Temurin 25.0.4.1 in the tested image), JVM mode fast-jar built with Maven |
| Example | apps/docs/integrations/quarkus/ in the Bunkhouse repository |
You need:
- Your application’s exact origin registered on the site (for example
https://app.example.com). - The site’s public key (
pk_...). - For identity tokens (recommended): a signing secret (
ss_...) and its key ID from the site’s Keys page. - For server mint instead: a mint key (
sk_...). - Existing sign-in through Quarkus Security. The example uses form authentication with two demo users; your own mechanism (form, OIDC, JWT, …) takes its place. Bunkhouse only needs one thing from it: an injected
SecurityIdentitywhose principal name is the signed-in user’s stable ID.
| File | Purpose |
|---|---|
src/main/java/example/AgentConfig.java |
Validates the public Bunkhouse configuration at startup |
src/main/java/example/AgentResource.java |
GET /api/agent-identity and POST /api/agent-session |
src/main/java/example/ContentSecurityPolicy.java |
Your Content-Security-Policy with the Bunkhouse sources added |
src/main/resources/templates/agent-embed-*.html |
The script tags for one installation mode (Qute) |
src/main/resources/META-INF/resources/agent-identity.js, agent-auth-headers.js |
Page hooks, loaded before the loader |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment. Quarkus maps these variables to the agent.* configuration properties; only AGENT_PUBLIC_KEY and the origins ever reach the browser:
AGENT_PUBLIC_KEY=pk_...AGENT_SIGNING_SECRET=ss_... # server onlyAGENT_SIGNING_KID=... # key ID shown with the signing secretAGENT_ALLOWED_ORIGINS='["https://app.example.com"]'# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.aiAgentConfig is a @Startup bean, so a wrong public key or origin stops the application from starting. It is also @TemplateData, so Qute templates can read its public fields:
package example;
import java.io.IOException;import java.net.URI;import java.util.Arrays;import java.util.List;
import jakarta.inject.Inject;import jakarta.inject.Singleton;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.qute.TemplateData;import io.quarkus.runtime.Startup;
/** * Bunkhouse public configuration (AGENT_PUBLIC_KEY, AGENT_ALLOWED_ORIGINS, AGENT_API_BASE, AGENT_EMBED_ORIGIN), * validated once at startup. Signing and mint credentials are read only by the endpoints that use them, so * each mode needs only its own secret. */@Startup@Singleton@TemplateDatapublic class AgentConfig { public final String publicKey; public final List<String> allowedOrigins; public final String apiBase; public final String embedOrigin;
@Inject AgentConfig( @ConfigProperty(name = "agent.public-key", defaultValue = "") String publicKey, @ConfigProperty(name = "agent.allowed-origins", defaultValue = "[]") String allowedOrigins, @ConfigProperty(name = "agent.api-base", defaultValue = "https://api.bunkhouse.ai") String apiBase, @ConfigProperty(name = "agent.embed-origin", defaultValue = "https://embed.bunkhouse.ai") String embedOrigin, ObjectMapper json) throws IOException { if (!publicKey.startsWith("pk_")) throw new IllegalStateException("AGENT_PUBLIC_KEY must be the site's public key (pk_...)"); String[] origins = json.readValue(allowedOrigins, String[].class); if (origins.length == 0) throw new IllegalStateException("AGENT_ALLOWED_ORIGINS must be a JSON array such as [\"https://app.example.com\"]"); this.publicKey = publicKey; this.allowedOrigins = Arrays.stream(origins).map(o -> exactOrigin("AGENT_ALLOWED_ORIGINS", o)).toList(); this.apiBase = exactOrigin("AGENT_API_BASE", apiBase); this.embedOrigin = exactOrigin("AGENT_EMBED_ORIGIN", embedOrigin); }
private static String exactOrigin(String name, String value) { URI uri = URI.create(value); String scheme = uri.getScheme(); boolean exact = ("https".equals(scheme) || "http".equals(scheme)) && uri.getHost() != null && value.equals(scheme + "://" + uri.getHost() + (uri.getPort() == -1 ? "" : ":" + uri.getPort())); if (!exact) throw new IllegalStateException(name + " must be an exact origin like https://app.example.com"); return value; }}The resource signs a ten-minute HS256 token with javax.crypto.Mac for the user Quarkus Security already authenticated. It rejects requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401: the endpoints check SecurityIdentity.isAnonymous() themselves instead of using @Authenticated, so no login redirect can occur), and marks every response Cache-Control: no-store. Quarkus REST answers unsupported methods with a 405 that has no Allow header, so a pre-matching filter in the same file returns 405 with Allow and no-store for the two paths. The subject comes only from the SecurityIdentity, never from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.
package example;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.time.Duration;import java.time.Instant;import java.util.Base64;import java.util.Map;import java.util.Optional;import java.util.concurrent.CompletableFuture;import java.util.concurrent.CompletionStage;
import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
import jakarta.inject.Inject;import jakarta.ws.rs.GET;import jakarta.ws.rs.HeaderParam;import jakarta.ws.rs.POST;import jakarta.ws.rs.Path;import jakarta.ws.rs.container.ContainerRequestContext;import jakarta.ws.rs.core.MediaType;import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;import org.jboss.logging.Logger;import org.jboss.resteasy.reactive.server.ServerRequestFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.security.identity.SecurityIdentity;
/** * Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session (server mint * mode). Both vouch only for the user Quarkus Security already authenticated. */@Path("/")public class AgentResource { private static final Logger LOG = Logger.getLogger(AgentResource.class); private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding(); private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
/** Your existing authentication: its principal name is the signed-in user's stable, opaque ID. */ @Inject SecurityIdentity identity;
@Inject AgentConfig agent;
@Inject ObjectMapper json;
@ConfigProperty(name = "agent.signing-secret") Optional<String> signingSecret;
@ConfigProperty(name = "agent.signing-kid") Optional<String> signingKid;
@ConfigProperty(name = "agent.embed-secret-key") Optional<String> embedSecretKey;
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */ String signIdentityToken(String userId, String secret, String kid) throws Exception { long iat = Instant.now().getEpochSecond(); String header = BASE64URL.encodeToString(json.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT", "kid", kid))); String payload = BASE64URL.encodeToString(json.writeValueAsBytes(Map.of("sub", userId, "aud", agent.publicKey, "iat", iat, "exp", iat + 600))); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256")); return header + "." + payload + "." + BASE64URL.encodeToString(mac.doFinal((header + "." + payload).getBytes(UTF_8))); }
@GET @Path("api/agent-identity") public Response identityToken(@HeaderParam("Sec-Fetch-Site") String fetchSite) throws Exception { if (!"same-origin".equals(fetchSite)) return error(403, "forbidden"); if (identity.isAnonymous()) return error(401, "unauthorized"); if (signingSecret.isEmpty()) return notConfigured("AGENT_SIGNING_SECRET"); if (signingKid.isEmpty()) return notConfigured("AGENT_SIGNING_KID"); String token = signIdentityToken(identity.getPrincipal().getName(), signingSecret.get(), signingKid.get()); return noStore(Response.ok(Map.of("identityToken", token), MediaType.APPLICATION_JSON_TYPE)); }
@POST @Path("api/agent-session") public CompletionStage<Response> session(@HeaderParam("Origin") String origin, @HeaderParam("X-Agent-Embed") String embed, @HeaderParam("Sec-Fetch-Site") String fetchSite) throws Exception { // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. if (origin == null || !agent.allowedOrigins.contains(origin) || !"1".equals(embed) || !"same-origin".equals(fetchSite)) { return CompletableFuture.completedFuture(error(403, "forbidden")); } if (identity.isAnonymous()) return CompletableFuture.completedFuture(error(401, "unauthorized")); if (embedSecretKey.isEmpty()) return CompletableFuture.completedFuture(notConfigured("AGENT_EMBED_SECRET_KEY")); Map<String, String> body = Map.of("publicKey", agent.publicKey, "mode", "signed", "userId", identity.getPrincipal().getName(), "origin", origin); HttpRequest request = HttpRequest.newBuilder(URI.create(agent.apiBase + "/v1/embed/sessions")) .timeout(Duration.ofSeconds(15)) .header("Authorization", "Bearer " + embedSecretKey.get()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofByteArray(json.writeValueAsBytes(body))) .build(); return HTTP.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()) .thenApply(upstream -> noStore(Response.status(upstream.statusCode()) .type(upstream.headers().firstValue("Content-Type").orElse(MediaType.APPLICATION_JSON)) .entity(upstream.body()))) .exceptionally(failure -> error(502, "session_service_unavailable")); }
/** Any other method on the two endpoints: 405 with Allow, never cached. */ public static class MethodNotAllowed { private static final Map<String, String> ALLOW = Map.of("/api/agent-identity", "GET", "/api/agent-session", "POST");
@ServerRequestFilter(preMatching = true) public Response filter(ContainerRequestContext request) { String allow = ALLOW.get(request.getUriInfo().getRequestUri().getPath()); String method = request.getMethod(); if (allow == null || method.equals(allow) || (method.equals("HEAD") && allow.equals("GET"))) return null; return noStore(Response.status(405).header("Allow", allow).type(MediaType.APPLICATION_JSON_TYPE).entity(Map.of("error", "method_not_allowed"))); } }
private static Response noStore(Response.ResponseBuilder response) { return response.header("Cache-Control", "no-store").build(); }
private static Response error(int status, String code) { return noStore(Response.status(status).type(MediaType.APPLICATION_JSON_TYPE).entity(Map.of("error", code))); }
private static Response notConfigured(String variable) { LOG.errorf("Bunkhouse: %s is not set", variable); return error(503, "not_configured"); }}The injected SecurityIdentity is the seam to your authentication. If your principal name is a username or email address rather than a stable, opaque ID, look your user ID up at that point.
In the page head, load the identity hook and the auth-headers hook (see Auth headers), then the loader, before your application’s own scripts: window.AgentEmbedConfig must be defined before the loader runs. The hooks are external same-origin scripts served from META-INF/resources, so no inline script needs a CSP exception:
{! Identity token mode (recommended): both page hooks first, then the loader, before the app's scripts. !}<script src="/agent-identity.js" nonce="{nonce}"></script><script src="/agent-auth-headers.js" nonce="{nonce}"></script><script src="{agent.embedOrigin}/embed.js" nonce="{nonce}" data-public-key="{agent.publicKey}" data-hints-url="/agent-hints.json"></script>// Identity token mode: the embed calls this hook whenever it starts a session for the signed-in user.window.AgentEmbedConfig = { ...window.AgentEmbedConfig, getIdentityToken: async () => { const res = await fetch("/api/agent-identity", { credentials: "same-origin", cache: "no-store" }); if (!res.ok) throw new Error("Not signed in"); return (await res.json()).identityToken; },};{#if csrfToken}<meta name="csrf-token" content="{csrfToken}" />{/if}{#if mode == 'mint'}{#include agent-embed-mint /}{#else if mode == 'anonymous'}{#include agent-embed-anonymous /}{#else}{#include agent-embed-identity /}{/if}<!-- The application's own JavaScript comes after the loader. --><script src="/app.js" nonce="{nonce}" defer></script>The page resource passes the configuration and the response’s CSP nonce to the layout:
private TemplateInstance page(String heading, String mode, RoutingContext rc) throws Exception { String nextUrl = rc.request().uri(); boolean signedIn = !identity.isAnonymous(); return layout // Test-shell switch between the three installation modes (?mode=); a real app uses exactly one. .data("mode", "mint".equals(mode) || "anonymous".equals(mode) ? mode : "identity") .data("agent", agent) .data("nonce", rc.get(ContentSecurityPolicy.NONCE)) .data("heading", heading) .data("user", signedIn ? identity.getAttribute("name") : null) .data("csrfToken", signedIn ? csrfToken() : null) .data("nextUrl", nextUrl) .data("loginAction", "/login?next=" + URLEncoder.encode(nextUrl, UTF_8));}The example layout includes one of the three agent-embed-* fragments, selected by a test-only ?mode= switch. Your application uses exactly one.
Server mint alternative
Section titled “Server mint alternative”Instead of the identity hook, add data-session-endpoint to the loader. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
{! Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. !}<script src="/agent-auth-headers.js" nonce="{nonce}"></script><script src="{agent.embedOrigin}/embed.js" nonce="{nonce}" data-public-key="{agent.publicKey}" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script>POST /api/agent-session in AgentResource.java above accepts only requests with an Origin from AGENT_ALLOWED_ORIGINS, X-Agent-Embed: 1 and Sec-Fetch-Site: same-origin (403 otherwise), authenticates the user (401), then creates the session with the mint key through java.net.http.HttpClient and returns the API’s status and body unchanged with Cache-Control: no-store. If the API is unreachable it returns 502 without details. Set AGENT_EMBED_SECRET_KEY=sk_... on the server; the identity endpoint does not need it.
The embed’s POST carries no CSRF token; the three header checks are that route’s protection. The Quarkus REST CSRF extension (quarkus-rest-csrf) filters every unsafe REST request and has no per-path exclusion, and by default it rejects JSON POSTs without its token with a 400, including this one. The example therefore protects its own API with a session token checked in the route (below) rather than with that extension.
If your application already uses quarkus-rest-csrf, serve POST /api/agent-session as a Vert.x route instead of a REST resource method: a reactive route (@Route from quarkus-reactive-routes) or a handler on the Router you receive with @Observes Router, applying the same three header checks before the authentication check. The extension’s check is a Quarkus REST request filter, and Quarkus REST is mounted behind such routes (route order 20 500 without Servlet in Quarkus 3.40.1), so it never sees that request.
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need no identity hook and no session endpoint. The example keeps its auth-headers hook in front of the loader. Enable anonymous access for the site in the dashboard first and set its monthly token and spend limits; usage is charged to your account’s credit and stops at a limit. Anyone who can load your page can start one, so it is not proof of identity.
{! Anonymous mode (enable it for the site first): the auth-headers hook, then the loader. !}<script src="/agent-auth-headers.js" nonce="{nonce}"></script><script src="{agent.embedOrigin}/embed.js" nonce="{nonce}" data-public-key="{agent.publicKey}" data-hints-url="/agent-hints.json"></script>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The example signs in with Quarkus form authentication: the login form on every page posts to /login, an IdentityProvider checks the demo password, and Quarkus stores the principal in an encrypted, HttpOnly, SameSite=Lax cookie (Secure over HTTPS). The landing page /after-login redirects back to the page the form was on. Sign-out is a form post that calls FormAuthenticationMechanism.logout(identity):
# Form authentication (the example's stand-in for your login): POST /login with username and password,# an encrypted, HttpOnly, SameSite=Lax session cookie, no redirects to login or error pages, so the# Bunkhouse endpoints answer signed-out requests with 401. Secure is set when the request is HTTPS.quarkus.http.auth.form.enabled=truequarkus.http.auth.form.post-location=/loginquarkus.http.auth.form.username-parameter=usernamequarkus.http.auth.form.password-parameter=passwordquarkus.http.auth.form.login-page=quarkus.http.auth.form.error-page=quarkus.http.auth.form.landing-page=/after-loginquarkus.http.auth.form.landing-page-query-params=nextquarkus.http.auth.form.http-only-cookie=truequarkus.http.auth.form.cookie-same-site=laxquarkus.http.auth.session.encryption-key=${DEMO_SESSION_SECRET}Every account change therefore loads a new document, and a new document starts a new embed with no session, so the previous user’s session can never carry over. If your application changes the signed-in user without a full page load (a fetch login, a single-page frontend), call window.AgentEmbed?.resetSession() right after the login, logout or account switch succeeds and before the new user’s requests. Render the loader once in the layout, not in per-page templates.
Auth headers, user IDs and CSP
Section titled “Auth headers, user IDs and CSP”The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example’s notes API requires an X-CSRF-Token header for the signed-in user, rendered into a <meta> tag by the layout:
// The app's own CSRF token (rendered by the server for the signed-in session) for the assistant's calls// to this app's API. Values never reach the model or the sandbox.window.AgentEmbedConfig = { ...window.AgentEmbedConfig, getAuthHeaders: () => { const token = document.querySelector('meta[name="csrf-token"]')?.content; return token ? { "X-CSRF-Token": token } : {}; },};/** * The signed-in session's CSRF token. Form sessions are stateless encrypted cookies, so the token is an HMAC * of the user ID under the session secret rather than a stored random value. * ponytail: bound to the user, not to one login; an app with server-side sessions stores a random token there. */private String csrfToken() throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(sessionSecret.getBytes(UTF_8), "HmacSHA256")); return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(("csrf:" + identity.getPrincipal().getName()).getBytes(UTF_8)));}The example’s CSRF token (an HMAC of the user ID) is a test-fixture shortcut that never rotates, not a scheme to reuse; use a random per-session token (or your framework’s CSRF support) in your app.
Use your application’s existing CSRF token source in the hook. Each mode’s fragment loads it before the loader. It merges into window.AgentEmbedConfig, so it works with the identity hook in either order. Its values never reach the model or the sandbox.
Send a stable, opaque user ID (the example’s fixture-alice), not a name or email address.
Add the Bunkhouse sources to your existing policy rather than replacing it. The example registers a Vert.x filter that sets a fresh nonce per response and stores it on the routing context for the templates; no 'unsafe-inline' or 'unsafe-eval' is needed:
package example;
import java.security.SecureRandom;import java.util.Base64;
import jakarta.enterprise.context.ApplicationScoped;import jakarta.enterprise.event.Observes;
import io.quarkus.vertx.http.runtime.filters.Filters;
/** * The app's existing Content-Security-Policy with the Bunkhouse sources added: the embed origin to script-src * and frame-src, the API to connect-src. A fresh nonce per response (routing context key {@value #NONCE}) goes * on the page's script tags. */@ApplicationScopedpublic class ContentSecurityPolicy { public static final String NONCE = "cspNonce"; private static final SecureRandom RANDOM = new SecureRandom();
void register(@Observes Filters filters, AgentConfig agent) { filters.register(rc -> { byte[] bytes = new byte[16]; RANDOM.nextBytes(bytes); String nonce = Base64.getEncoder().encodeToString(bytes); rc.put(NONCE, nonce); rc.response().putHeader("Content-Security-Policy", String.join("; ", "default-src 'self'", "script-src 'self' 'nonce-" + nonce + "' " + agent.embedOrigin, "style-src 'self' 'nonce-" + nonce + "'", "frame-src " + agent.embedOrigin, "connect-src 'self' " + agent.apiBase, "img-src 'self' data:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'")); rc.next(); }, 100); }}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts quarkusThe run builds the example image, starts it against a fresh local site and drives the real pane in Chromium: identity, mint and anonymous sessions, endpoint guards, sign-out and account switches, discovery of the page’s startup request, a CSRF-protected write through getAuthHeaders, CSP and secret checks. In your own app, sign in and open the Assistant launcher: the first message creates a signed session.
| Symptom | Check |
|---|---|
/api/agent-identity returns 401 |
The injected SecurityIdentity is anonymous: the session cookie is missing or expired, or your authentication mechanism does not cover /api/*. |
| A login page or 302 instead of 401 | An HTTP permission or @Authenticated on the endpoints triggers your mechanism’s challenge; let the resource check isAnonymous() instead. |
| 403 from either endpoint | The request is cross-site or missing Sec-Fetch-Site; for mint, Origin must exactly match AGENT_ALLOWED_ORIGINS and the loader must carry data-session-endpoint. |
| 400 from the mint endpoint | quarkus-rest-csrf rejected the embed’s POST; serve /api/agent-session as a Vert.x route as described in the server mint section. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set. |
| The pane reports a CSP block | Add the embed origin to script-src and frame-src and the API to connect-src, and put the response’s nonce on the script tags. |
| Startup requests are not discovered | The loader must come before your application’s scripts in the head. |
| Two launchers | Render the loader once, in the layout. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in templates; keep ss_ and sk_ values in server environment variables. |
Related
Section titled “Related”- Installation, Security and the Page API
- Quarkus: Quarkus REST, Security overview, Form authentication, Qute