HTTP hooks

Hooks let you plug custom logic into pykrete without changing the backend: the backend POSTs a JSON document to the frontend (freshfront) at /hooks/<name> and uses the JSON answer. The default implementations live in freshfront/routes/hooks/*.ts; edit them to customize behavior.

Hooks at a glance

Hook Fires On failure Default implementation

identity

A user was identified in an interactive flow: sign-in page, /authorize (also silent prompt=none), device code verification, adding a passkey, accepting an invitation

The flow is aborted

Does nothing

claims

Every successful token request (authorization_code, refresh_token, device code), before the tokens are signed

The token request fails with server_error

Adds NetBird claims, see NetBird

emails/invitation

An organization invitation with an email was created

The invitation is deleted, creation fails

Renders the bundled invitation email

emails/email-verification

A user added an email address or asked to resend the verification link

The link is invalidated, the user sees "Could not send the verification link, try again later"

Renders the bundled verification email

There are no other hook points.

The emails/* hooks only render content. The backend sends the result over SMTP, see Email hooks and Email configuration.

Delivery

Configuration

Hooks are configured in the backend's config file (see Configuration):

{
  "session_secret": "<session-secret>",
  "frontend": {
    "upstream": "unix:/run/pykrete/freshfront.sock",
    "hooks_secret": "<hooks-secret>"
  }
}

upstream is either unix:<path> or http://<host>:<port> (plain HTTP, no path). It is the same upstream the backend proxies all unhandled requests (UI pages, docs) to; hooks reuse it.

Give freshfront the same secret in the HOOKS_SECRET environment variable; it is read once at startup. freshfront's HOOKS_SECRET must match the backend's frontend.hooks_secret; otherwise every hook fails, and with it every sign-in and token request. Set both or neither.

Hooks are disabled when frontend is missing or hooks_secret is missing or null. Then:

  • identity and claims are skipped (no custom claims).

  • Emails can't be rendered. If email is configured, sending fails ("sending email requires frontend hooks to render it"). If it isn't, the backend logs the email's raw data, including the link, at info level ("email delivery is not configured") and reports success.

Request

POST /hooks/claims HTTP/1.1
Host: frontend
Authorization: Bearer <hooks-secret>
Content-Type: application/json

{"grant_type": "authorization_code", ...}

Each call opens a new HTTP/1.1 connection to the upstream.

Response and failure

The hook must answer with a 2xx status and a JSON body within 5 seconds; the body may be at most 1 MiB. null is a valid body. Anything else fails the hook:

  • a non-2xx status (including redirects),

  • an empty body (204 No Content counts as failure: the body must be JSON),

  • invalid JSON or, for claims and emails/*, JSON of the wrong shape,

  • connection errors or the 5 second timeout.

Hooks fail closed: what happens next is listed in the table above. The backend logs the cause, for example hook: hook returned 404 Not Found.

The freshfront middleware (freshfront/routes/hooks/_middleware.ts) compares the Authorization header with Bearer $HOOKS_SECRET (constant time) and answers 404 on mismatch or when HOOKS_SECRET is unset.

freshfront pykrete backend Relying party freshfront pykrete backend Relying party POST /oauth/token check grant, build claims POST /hooks/claims (Bearer hooks_secret) 200 {"id_token": {...}, "access_token": {...}} merge, reject reserved names tokens

Keeping hooks internal

The backend never proxies /hooks or /hooks/... from outside: such requests get 404, also after path normalization (/x/../hooks/..., %2e segments, backslashes, double slashes).

freshfront's own listener serves /hooks/* too. Without FRESHFRONT_SOCKET it listens on TCP port 8000 on all interfaces, so run it on a unix socket (FRESHFRONT_SOCKET=/run/pykrete/freshfront.sock with "upstream": "unix:/run/pykrete/freshfront.sock") or firewall the port. See Running.

identity

Called at the end of every interactive identification, also when the user was already signed in and nothing was shown.

{"user_id": "U0123456789abcdefghijklmnopqrstuv"}

The response body is ignored (return null). A failure aborts the flow: /authorize redirects to the client with error=server_error ("internal error, try again in a moment"), other flows show "Internal error, try again later". This makes the hook usable as a sign-in gate, for example to block suspended users. It does not run on token refresh; to cut off existing grants as well, fail the claims hook for that user.

claims

Called by the token endpoint after the grant, scopes and organization were validated and pykrete's own claims were built, and before the tokens are signed. It runs for refresh too, so claims stay fresh.

Request

{
  "grant_type": "authorization_code",
  "client_id": "C0123456789abcdefghijklmnopqrstuv",
  "audiences": ["https://api.example.com"],
  "scope": "openid organizations profile",
  "user": {
    "id": "U0123456789abcdefghijklmnopqrstuv",
    "public_metadata": {},
    "private_metadata": {"plan": "enterprise"}
  },
  "organization": {
    "id": "O0123456789abcdefghijklmnopqrstuv",
    "slug": "acme",
    "name": "Acme Inc.",
    "public_metadata": {"tenant": "acme-eu"},
    "private_metadata": {"netbird": {"domain": "acme.netbird.example.com"}},
    "roles": ["role:org-admin"]
  },
  "organizations": ["O0123456789abcdefghijklmnopqrstuv", "O9876543210zyxwvutsrqponmlkjihgfe"]
}
Field Meaning

grant_type

authorization_code, refresh_token or urn:ietf:params:oauth:grant-type:device_code

client_id

Client the tokens are issued to

audiences

Audience identifiers (audienceIdentifier) of the token's audiences, not the audience ids (A...) used in the access token's aud

scope

Space-separated granted scopes

user

The user and their metadata ({} when none was set)

organization

The active organization (see Organizations) with both metadata objects and the user's role ids in it, or null

organizations

Ids of the organizations listed in the orgs claim. Empty unless the organizations scope was granted.

Response

{
  "id_token": {"groups": ["role:org-admin"], "tenant": "acme-eu"},
  "access_token": {"tenant": "acme-eu"}
}

Both keys are optional; other top-level keys are ignored. null or {} adds nothing. Values can be any JSON.

Reserved claims

The whole token request fails (fail closed) if the hook returns, for either token:

  • a reserved name: iss, sub, aud, exp, iat, nbf, jti, azp, scope, nonce, auth_time, at_hash, c_hash, acr, amr, sid, orgs;

  • any name starting with org_;

  • a name pykrete already set in that token. In the ID token these are name, picture, updated_at (scope profile), email, email_verified (scope email), orgs and org_*; in the access token org_*.

The error the client sees is server_error; the backend log says hook: hook tried to set reserved claim <name>.

Where the claims end up

  • id_token claims go into the ID token and are returned by /userinfo. They are dropped when the openid scope was not granted (no ID token is issued).

  • access_token claims go into the access token JWT. Token introspection does not report them.

  • Claims are stored with the issued token; a refresh calls the hook again.

A failed claims hook answers the token request with HTTP 400 and {"error": "server_error", "error_description": "Something is wrong with the server, try again later"}. For the code grant the authorization code is already used up and the user has to sign in again; a refresh token is not rotated, so the client can retry.

Email hooks

pykrete sends email itself, but freshfront writes it. For each email the backend POSTs the data to /hooks/emails/<kind>. freshfront answers with the rendered message, and the backend wraps it into a MIME message and sends it over SMTP. The backend sets From (from email.from), To, Date and Message-ID, so a template can't change the recipient or the sender.

Every request has to, the recipient address, plus the fields of that kind. expires_at is a unix timestamp in seconds. url contains a secret: anyone who gets it can use it, so do not log it.

The response must be:

{
  "subject": "Confirm your email address",
  "text": "Open this link to confirm the address: ...",
  "html": "<!DOCTYPE html><html>...</html>"
}
  • subject: required, non-empty, no control characters (no line breaks).

  • text: required, non-empty. Sent as the text/plain part.

  • html: optional. When present and non-empty, the message is multipart/alternative with both parts.

A response that breaks these rules fails the hook like any other hook failure.

emails/invitation

Called when an organization invitation with an email is created, by an organization member on /account/organizations/<slug>/invitations, through manage { organization { invite } }, or by an admin via orgs { createOrganizationInvitation }. Link-only and user-bound invitations do not call it. See Invitations.

{
  "to": "alice@example.org",
  "organization": {"id": "O0123456789abcdefghijklmnopqrstuv", "slug": "acme", "name": "Acme Inc."},
  "inviter": "Bob",
  "url": "https://auth.infra.delta.rocks/invite#<code>",
  "expires_at": 1793601600
}

inviter is the display name of whoever created the invitation, or null. The invited person has to sign in with an account that has to as a verified address. If rendering or sending fails, the invitation is deleted and the creator sees "Could not send the invitation email, try again later"; the cause is only in the backend log.

emails/email-verification

Called when a user adds an email address (me { addEmail }) or asks for a new link (me { resendEmailVerification }), both on the account email page.

{
  "to": "alice@example.org",
  "url": "https://auth.infra.delta.rocks/account/emails/verify?code=<code>",
  "expires_at": 1793083200
}

The link is valid for 24 hours and only for the signed-in owner. A new link for the same address can be sent at most once a minute, and a user can have at most 5 unverified addresses. If rendering or sending fails, the new link is invalidated.

Customizing hooks

Edit the freshfront handlers

Each hook is a Fresh route in freshfront/routes/hooks/: identity.ts, claims.ts, emails/invitation.ts, emails/email-verification.ts. The _middleware.ts in the same directory already checks the secret, so handlers only deal with the payload.

Example: keep the NetBird mapping and add a tenant claim from the active organization's public metadata (freshfront/routes/hooks/claims.ts, only the handler changes):

export const handler = define.handlers({
	async POST(ctx) {
		const hook: ClaimsHook = await ctx.req.json();
		const netbird = netbirdClaims(hook);
		const tenant = hook.organization?.public_metadata.tenant;
		const extra: Claims = typeof tenant === "string" ? { tenant } : {};
		const output: ClaimsHookOutput = {
			id_token: { ...netbird, ...extra },
			access_token: { ...netbird, ...extra },
		};
		return Response.json(output);
	},
});

Keep handlers well under the 5 second budget: the backend gives up after 5 seconds and treats it as a failure.

Change email templates

The email routes only parse the request and call a template from freshfront/server/emails.tsx, which exports the request types and one function per email returning {subject, text, html}.

The HTML part is baked once when freshfront starts: Template.bake from @delta-docs/build-selfcontained/template renders the Preact JSX, moves the rules of freshfront/server/emails.css into style attributes (many mail clients ignore stylesheets), and embeds a Forkex Sans subset holding only the glyphs the email can show. A request only fills in the placeholders.

  • Mark request values with <Placeholder name="..." expectedCharacters={...} /> in text and placeholder("...") in attributes. expectedCharacters picks the glyphs that go into the font: the inside of a regex character class ("a-zA-Z0-9"), a RegExp, or a preset from chars (ascii, latin, latinExtended, cyrillic, greek, date(format)). Other characters still show, in the fallback font.

  • Values derived from the request (the host of url, the formatted expiry) are placeholders of their own: compute them in the email function and pass them to render.

  • Write colors as light-dark(<light>, <dark>); the palette variables from @delta-docs/chrome/colors.css already are. Light values are inlined, dark values go into a prefers-color-scheme: dark block that Apple Mail, Outlook for Mac and Thunderbird apply. Gmail and Outlook for Windows ignore it and invert colors on their own.

  • Use tables for layout, mail clients handle flexbox and grid poorly.

Every expected character adds a glyph to each weight that can show it. The bundled invitation is about 39 KB with chars.latin for names and about 58 KB with chars.latinExtended; Gmail clips messages over about 102 KB.

Example: a different invitation subject.

export function invitation(email: InvitationEmail): RenderedEmail {
	const org = oneLine(email.organization.name);
	return {
		subject: `Your invitation to ${org}`,
		// ...
	};
}

Strip control characters from anything user-controlled that goes into subject (the bundled oneLine helper does that); a subject with a line break fails the hook. render escapes values in the HTML part.

The freshfront dev server lists every email with sample data at /dev/emails, with the text part inline and a link to the HTML part. The page answers 404 outside the dev server.

Serve hooks from another service

There is no per-hook URL setting: hooks always go to frontend.upstream, which also serves the UI. To run hooks elsewhere, either

  • call your service from the freshfront handler, or

  • point upstream at a reverse proxy that sends /hooks/* to your service and everything else to freshfront. Your service then has to check Authorization: Bearer <hooks-secret> itself and follow the request and response formats on this page.

A minimal standalone claims hook with Deno:

const secret = Deno.env.get("HOOKS_SECRET");

async function digest(value: string) {
	return new Uint8Array(
		await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
	);
}

async function authorized(req: Request) {
	if (!secret) return false;
	const [a, b] = await Promise.all([
		digest(req.headers.get("authorization") ?? ""),
		digest(`Bearer ${secret}`),
	]);
	let diff = 0;
	for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
	return diff === 0;
}

Deno.serve({ hostname: "127.0.0.1", port: 9000 }, async (req) => {
	const { pathname } = new URL(req.url);
	if (req.method !== "POST" || !await authorized(req)) {
		return new Response(null, { status: 404 });
	}
	const hook = await req.json();
	switch (pathname) {
		case "/hooks/claims": {
			const plan = hook.user.private_metadata.plan;
			const claims = typeof plan === "string" ? { plan } : {};
			return Response.json({ id_token: claims, access_token: claims });
		}
		case "/hooks/identity":
			return Response.json(null);
		case "/hooks/emails/invitation":
		case "/hooks/emails/email-verification":
			return Response.json({
				subject: "Action needed for your account",
				text: `Open this link: ${hook.url}`,
			});
	}
	return new Response(null, { status: 404 });
});
Note
A service that replaces freshfront for /hooks/* must answer all four hook names, or sign-in (identity), token issuance (claims) or email fail.

Troubleshooting

Symptom Likely cause

Every sign-in ends with "Internal error" or error=server_error, backend log shows a hook 404 (hook returned 404 Not Found or Hook(Status(404)))

HOOKS_SECRET in freshfront missing or different from hooks_secret

Token requests fail with server_error, log shows hook tried to set reserved claim <name>

The claims hook returns a reserved name or one pykrete already set

Log shows EOF while parsing a value

A hook answered with an empty body; return null

Log shows hook timed out (or Timeout)

The hook took longer than 5 seconds

Emails never arrive, backend log shows email delivery is not configured

No email in the config file; the backend only logs emails

Sending fails, log shows sending email requires frontend hooks to render it

email is configured but hooks are disabled (no hooks_secret)

Sending fails, log shows rendered email has an invalid subject

The template put a line break or other control character into the subject

Sending fails, log shows smtp: ... or smtp timed out

Wrong email.smtp URL or credentials, or the SMTP server is unreachable