Micronaut
This guide adds Bunkhouse to a server-rendered Micronaut application. 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 | Micronaut platform 5.2.4 (core 5.2.20) on Netty, Micronaut Views 6.3.1 with Thymeleaf 3.1, Micronaut Security 5.4.1 session authentication and CSRF |
| Runtime | Java 25 (Eclipse Temurin 25.0.4.1 in the tested image), built with Maven |
| Example | apps/docs/integrations/micronaut/ 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 Micronaut Security. The example uses session authentication with an
HttpRequestAuthenticationProviderfor two demo users; your own authentication provider takes its place. Bunkhouse only needs one thing from it: anAuthenticationwhose 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/AgentController.java |
GET /api/agent-identity and POST /api/agent-session |
src/main/resources/application.properties |
Session security, the CSRF exemption for the mint endpoint, and your Content-Security-Policy with the Bunkhouse sources added |
src/main/resources/views/agent-embed-*.html |
The script tags for one installation mode (Thymeleaf fragments) |
src/main/resources/public/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. 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.aipackage example;
import io.micronaut.context.annotation.Context;import io.micronaut.core.type.Argument;import io.micronaut.json.JsonMapper;import java.io.IOException;import java.net.URI;import java.net.URISyntaxException;import java.util.List;
/** * Bunkhouse public configuration, validated once at startup ({@code @Context} beans are created eagerly, so * a misconfiguration stops the app). Signing and mint credentials are read only by the endpoints that use * them, so each mode needs only its own secret. */@Contextpublic final class AgentConfig { private final String publicKey; private final List<String> allowedOrigins; private final String apiBase; private final String embedOrigin;
AgentConfig(JsonMapper json) { publicKey = env("AGENT_PUBLIC_KEY", ""); if (!publicKey.startsWith("pk_")) throw new IllegalStateException("AGENT_PUBLIC_KEY must be the site's public key (pk_...)"); List<String> origins; try { origins = json.readValue(env("AGENT_ALLOWED_ORIGINS", "[]"), Argument.listOf(String.class)); } catch (IOException e) { origins = List.of(); } if (origins == null || origins.isEmpty()) { throw new IllegalStateException("AGENT_ALLOWED_ORIGINS must be a JSON array such as [\"https://app.example.com\"]"); } allowedOrigins = origins.stream().map(o -> exactOrigin("AGENT_ALLOWED_ORIGINS", o)).toList(); apiBase = exactOrigin("AGENT_API_BASE", env("AGENT_API_BASE", "https://api.bunkhouse.ai")); embedOrigin = exactOrigin("AGENT_EMBED_ORIGIN", env("AGENT_EMBED_ORIGIN", "https://embed.bunkhouse.ai")); }
private static String env(String name, String fallback) { String value = System.getenv(name); return value == null || value.isEmpty() ? fallback : value; }
private static String exactOrigin(String name, String value) { try { URI uri = new URI(value); boolean web = "https".equals(uri.getScheme()) || "http".equals(uri.getScheme()); if (web && uri.getHost() != null && uri.getRawUserInfo() == null && (uri.getScheme() + "://" + uri.getRawAuthority()).equals(value)) return value; } catch (URISyntaxException ignored) { // reported below } throw new IllegalStateException(name + " must be an exact origin like https://app.example.com"); }
public String getPublicKey() { return publicKey; }
public List<String> getAllowedOrigins() { return allowedOrigins; }
public String getApiBase() { return apiBase; }
public String getEmbedOrigin() { return embedOrigin; }}The controller signs a ten-minute HS256 token with javax.crypto.Mac for the user Micronaut Security already authenticated. It rejects other methods (405 with Allow), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and its filter marks every response from both endpoints Cache-Control: no-store, including rejections from the security filters. The subject comes only from the request’s Authentication, 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 io.micronaut.core.annotation.Nullable;import io.micronaut.core.order.Ordered;import io.micronaut.http.HttpHeaders;import io.micronaut.http.HttpRequest;import io.micronaut.http.HttpResponse;import io.micronaut.http.HttpStatus;import io.micronaut.http.MutableHttpResponse;import io.micronaut.http.annotation.Controller;import io.micronaut.http.annotation.Get;import io.micronaut.http.annotation.Post;import io.micronaut.http.annotation.RequestFilter;import io.micronaut.http.annotation.ResponseFilter;import io.micronaut.http.annotation.ServerFilter;import io.micronaut.json.JsonMapper;import io.micronaut.security.annotation.Secured;import io.micronaut.security.authentication.Authentication;import io.micronaut.security.rules.SecurityRule;import java.io.IOException;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest.BodyPublishers;import java.net.http.HttpResponse.BodyHandlers;import java.security.GeneralSecurityException;import java.time.Duration;import java.time.Instant;import java.util.Base64;import java.util.Map;import java.util.concurrent.CompletableFuture;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import org.slf4j.Logger;import org.slf4j.LoggerFactory;
/** * Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session (server * mint mode). Both vouch only for the user your existing authentication already put on the request. */@Controller@Secured(SecurityRule.IS_ANONYMOUS) // the handlers answer 403 and 401 themselves: JSON, never a login redirectpublic class AgentController { static final String IDENTITY = "/api/agent-identity"; static final String SESSION = "/api/agent-session"; private static final Logger LOG = LoggerFactory.getLogger(AgentController.class); private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private final AgentConfig agent; private final JsonMapper json;
AgentController(AgentConfig agent, JsonMapper json) { this.agent = agent; this.json = json; }
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */ String signIdentityToken(String userId, String secret, String kid) throws IOException, GeneralSecurityException { 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.getPublicKey(), "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))); }
/** {@code user} is your authentication's principal for this request (null when signed out); its name is the user ID. */ @Get(IDENTITY) HttpResponse<?> identity(HttpRequest<?> request, @Nullable Authentication user) throws IOException, GeneralSecurityException { if (!"same-origin".equals(request.getHeaders().get("Sec-Fetch-Site"))) return error(HttpStatus.FORBIDDEN, "forbidden"); if (user == null) return error(HttpStatus.UNAUTHORIZED, "unauthorized"); String secret = System.getenv("AGENT_SIGNING_SECRET"); String kid = System.getenv("AGENT_SIGNING_KID"); if (secret == null || secret.isEmpty()) return notConfigured("AGENT_SIGNING_SECRET"); if (kid == null || kid.isEmpty()) return notConfigured("AGENT_SIGNING_KID"); return HttpResponse.ok(Map.of("identityToken", signIdentityToken(user.getName(), secret, kid))); }
@Post(SESSION) CompletableFuture<HttpResponse<?>> session(HttpRequest<?> request, @Nullable Authentication user) throws IOException { // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense // (application.properties exempts only this route from the CSRF filter). HttpHeaders headers = request.getHeaders(); String origin = headers.getOrigin().orElse(null); if (origin == null || !agent.getAllowedOrigins().contains(origin) || !"1".equals(headers.get("X-Agent-Embed")) || !"same-origin".equals(headers.get("Sec-Fetch-Site"))) { return CompletableFuture.completedFuture(error(HttpStatus.FORBIDDEN, "forbidden")); } if (user == null) return CompletableFuture.completedFuture(error(HttpStatus.UNAUTHORIZED, "unauthorized")); String key = System.getenv("AGENT_EMBED_SECRET_KEY"); if (key == null || key.isEmpty()) return CompletableFuture.completedFuture(notConfigured("AGENT_EMBED_SECRET_KEY")); byte[] body = json.writeValueAsBytes(Map.of("publicKey", agent.getPublicKey(), "mode", "signed", "userId", user.getName(), "origin", origin)); var upstream = java.net.http.HttpRequest.newBuilder(URI.create(agent.getApiBase() + "/v1/embed/sessions")) .timeout(Duration.ofSeconds(15)) .header("Authorization", "Bearer " + key) .header("Content-Type", "application/json") .POST(BodyPublishers.ofByteArray(body)) .build(); return HTTP.sendAsync(upstream, BodyHandlers.ofByteArray()) .<HttpResponse<?>>thenApply(res -> HttpResponse.ok(res.body()) .status(res.statusCode()) .contentType(res.headers().firstValue("Content-Type").orElse("application/json"))) .exceptionally(e -> error(HttpStatus.BAD_GATEWAY, "session_service_unavailable")); }
private static MutableHttpResponse<?> error(HttpStatus status, String code) { return HttpResponse.status(status).body(Map.of("error", code)); }
private static MutableHttpResponse<?> notConfigured(String variable) { LOG.error("Bunkhouse: {} is not set", variable); return error(HttpStatus.SERVICE_UNAVAILABLE, "not_configured"); }
/** Both endpoints: every response is noncacheable, and other HTTP methods get 405 with Allow. */ @ServerFilter({IDENTITY, SESSION}) static final class EndpointFilter implements Ordered { @RequestFilter @Nullable HttpResponse<?> methods(HttpRequest<?> request) { String allow = IDENTITY.equals(request.getPath()) ? "GET" : "POST"; if (allow.equals(request.getMethodName())) return null; return error(HttpStatus.METHOD_NOT_ALLOWED, "method_not_allowed").header(HttpHeaders.ALLOW, allow).header(HttpHeaders.CACHE_CONTROL, "no-store"); }
@ResponseFilter void noStore(MutableHttpResponse<?> response) { response.getHeaders().set(HttpHeaders.CACHE_CONTROL, "no-store"); }
@Override public int getOrder() { return HIGHEST_PRECEDENCE; // ahead of the security and CSRF filters, so their rejections are covered too } }}The @Nullable Authentication argument is the seam to your authentication: Micronaut Security binds whatever your authentication fetcher (session, JWT cookie, …) resolved for the request, and getName() must be a stable, opaque user ID. The controller is @Secured(SecurityRule.IS_ANONYMOUS) so that it, not the security filter, decides between 403 and 401.
In the page head, load the page hooks (the identity hook, then the auth-headers hook described below) and 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, so no inline script needs a CSP exception:
<!--/* Identity token mode (recommended): the page hooks first, then the loader, before the app's scripts. */--><th:block th:fragment="tags"> <script src="/agent-identity.js" th:nonce="${cspNonce}"></script> <script src="/agent-auth-headers.js" th:nonce="${cspNonce}"></script> <script th:src="|${agent.embedOrigin}/embed.js|" th:nonce="${cspNonce}" th:data-public-key="${agent.publicKey}" data-hints-url="/agent-hints.json"></script></th:block>// 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; },};<meta th:if="${user != null}" name="csrf-token" th:content="${csrfToken}" /><th:block th:replace="~{agent-embed-__${mode}__ :: tags}"></th:block><!--/* The application's own JavaScript comes after the loader. */--><script src="/app.js" th:nonce="${cspNonce}" defer></script>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 auth-headers hook, then the loader posting to the same-origin session endpoint. */--><th:block th:fragment="tags"> <script src="/agent-auth-headers.js" th:nonce="${cspNonce}"></script> <script th:src="|${agent.embedOrigin}/embed.js|" th:nonce="${cspNonce}" th:data-public-key="${agent.publicKey}" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script></th:block>POST /api/agent-session in AgentController.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 request (401), then creates the session with the mint key using 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.
Micronaut Security’s CSRF filter stays on for every other route. The embed’s POST carries no CSRF token, so exempt only /api/agent-session; the three header checks are that route’s protection:
# Session-bound CSRF tokens, sent as the csrfToken form field or the X-CSRF-TOKEN header, on form and# JSON writes. The Bunkhouse mint endpoint is the only exempt route: the embed's POST carries no CSRF# token, and that endpoint checks Origin, X-Agent-Embed and Sec-Fetch-Site instead.micronaut.security.csrf.signature-key=${DEMO_SESSION_SECRET}micronaut.security.csrf.repository.cookie.enabled=falsemicronaut.security.csrf.filter.content-types=application/x-www-form-urlencoded,multipart/form-data,application/jsonmicronaut.security.csrf.filter.regex-pattern=^(?!/api/agent-session$).*$The filter’s default content types are form posts only; the example adds application/json so that JSON writes to your API are covered too. signature-key is your application’s own server-side secret for signing CSRF tokens (keep it out of templates and source control); the example reads it from the test harness’s DEMO_SESSION_SECRET.
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need only the loader (the example keeps its auth-headers hook in front of it). 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. */--><th:block th:fragment="tags"> <script src="/agent-auth-headers.js" th:nonce="${cspNonce}"></script> <script th:src="|${agent.embedOrigin}/embed.js|" th:nonce="${cspNonce}" th:data-public-key="${agent.publicKey}" data-hints-url="/agent-hints.json"></script></th:block>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The example signs in and out with ordinary form posts (carrying the session’s CSRF token) that redirect back to the same page, through Micronaut Security’s Authenticator, SessionLoginHandler and SessionLogoutHandler. 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. The session login handler starts a new session on login; rejections are status codes rather than redirects, and session cookies are HttpOnly, SameSite=Lax and Secure unless the origin is plain HTTP:
# Session authentication. Rejections are 401/403 responses, not redirects to a login page.micronaut.security.authentication=sessionmicronaut.security.redirect.enabled=falsemicronaut.security.endpoints.login.enabled=falsemicronaut.security.endpoints.logout.enabled=falsemicronaut.security.intercept-url-map[0].pattern=/*.jsmicronaut.security.intercept-url-map[0].http-method=GETmicronaut.security.intercept-url-map[0].access[0]=isAnonymous()# Session ID in an HttpOnly, SameSite cookie only (not also in the Authorization-Info response header).micronaut.session.http.cookie-same-site=Laxmicronaut.session.http.header=false// Secure session cookies unless the origin is plain HTTP (the loopback test origin).boolean http = System.getenv().getOrDefault("APP_ORIGIN", "").startsWith("http://");Micronaut.build(args) .mainClass(Application.class) .properties(Map.of("micronaut.session.http.cookie-secure", !http)) .start();If your Micronaut app 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 uses Micronaut Security’s session-bound CSRF token: Micronaut Views adds it to the model as csrfToken, the layout renders it into a <meta> tag for signed-in users, and the CSRF filter checks the X-CSRF-TOKEN header on every write:
// The app's own CSRF token (Micronaut Security's session-bound token, rendered 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 hook merges into window.AgentEmbedConfig, so it combines with the identity hook; each mode’s fragment loads it before the loader. Its values never reach the model or the sandbox.
Send a stable, opaque user ID (the example’s fixture-alice) as the principal name, not a name or email address.
Add the Bunkhouse sources to your existing policy rather than replacing it. Micronaut Views’ CSP filter generates a fresh nonce per response ({#nonceValue}); the example’s page controller copies it from the request attribute CspFilter.NONCE_PROPERTY into the view model as cspNonce, next to agent (the AgentConfig bean), and every script tag carries th:nonce="${cspNonce}". No 'unsafe-inline' or 'unsafe-eval' is needed:
# The app's Content-Security-Policy with the Bunkhouse sources added: the embed origin to script-src and# frame-src, the API to connect-src. Micronaut Views generates a fresh nonce per response.micronaut.views.csp.enabled=truemicronaut.views.csp.generate-nonce=truemicronaut.views.csp.force-secure-random=truemicronaut.views.csp.policy-directives=default-src 'self'; \ script-src 'self' 'nonce-{#nonceValue}' ${AGENT_EMBED_ORIGIN:`https://embed.bunkhouse.ai`}; \ style-src 'self' 'nonce-{#nonceValue}'; \ frame-src ${AGENT_EMBED_ORIGIN:`https://embed.bunkhouse.ai`}; \ connect-src 'self' ${AGENT_API_BASE:`https://api.bunkhouse.ai`}; \ img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts micronautThe 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 |
No Authentication reached the controller: check micronaut.security.authentication and that your login stores the user (for sessions, through SessionLoginHandler). |
| 401 or a redirect instead of 403/405 | The endpoints need @Secured(SecurityRule.IS_ANONYMOUS) so they make the decision themselves; disable security redirects for these API routes. |
| 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. A 403 on mint can also be the CSRF filter: its regex-pattern must exempt /api/agent-session. |
| 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; put th:nonce="${cspNonce}" on every script tag. |
| 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
- Micronaut: User guide, Micronaut Security, Micronaut Views