feat: surface guest JWT as the embed method in the app deploy drawer

The deploy drawer explained the secret-URL embed but not the guest JWT path, so
the primary way to embed an app for a customer's own authenticated users was
undiscoverable. For a guest-mode app with guests enabled, show how to mint a
`jwt_guest_` token and append `guest.<jwt>` to the app URL, with a copyable
iframe template pre-filled with this app's workspace_id and app_path, and a note
that new guest emails are refused past the instance's free allowance (the live
count is shown just above).

Also log a guest JWT allowance refusal at warn, not info: the caller gets a bare
401 (the reason must not leak to an unauthenticated caller), so the log is the
admin's signal that the instance hit its guest cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-05 09:49:03 +02:00
co-authored by Claude Opus 4.8
parent daf9553896
commit c20f549fe0
2 changed files with 37 additions and 3 deletions
+3 -1
View File
@@ -799,7 +799,9 @@ async fn admit_and_record_guest_jwt(db: &DB, w_id: &str, email: &str, app_path:
match windmill_common::workspaces::guest_admission(&mut *tx, email).await {
Ok(()) => {}
Err(e @ windmill_common::error::Error::PermissionDenied(_)) => {
tracing::info!("guest JWT not admitted for {w_id}: {e:#}");
// The guest hits a bare 401 (the reason must not leak to an unauthenticated caller);
// warn so an admin sees the cap in logs, since it is the actionable signal here.
tracing::warn!("guest JWT refused (guest allowance) for {w_id}: {e:#}");
GUEST_JWT_REFUSED_CACHE.insert(cache_key, std::time::Instant::now());
return false;
}
@@ -176,6 +176,10 @@
}${customPath}`
)
// The app URL a guest JWT rides on: append `guest.<jwt>` and the viewer authenticates the
// token as a seatless guest. Uses the custom URL when set, else the public secret URL.
let guestJwtBase = $derived(customPath !== undefined ? fullCustomUrl : secretUrlHref)
// When embedding a raw app in an iframe inside another Windmill app (or any
// cross-origin-isolated page), the embedded document must set COEP. The
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
@@ -500,8 +504,8 @@
Anyone your identity provider authenticates can open this app without a Windmill account.
They join no workspace. Members of this workspace can open it too.
{#if guestUsage}
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance
in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
? 'every four guests count as one seat'
: 'new guests are refused until the count drops'}.
{/if}
@@ -543,6 +547,34 @@
{/if}
</div>
{#if policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
<div class="text-xs font-semibold text-emphasis">
Embed for your own authenticated users (guest JWT)
</div>
<div class="text-xs text-secondary">
To open this app for a user your own product already authenticates, mint a short-lived JWT
in your backend and append it to the app URL as <code>guest.&lt;jwt&gt;</code>. Each token
is its own seatless guest, confined to this app no shared secret and no Windmill
account, unlike the plain secret URL above.
</div>
<div class="text-xs text-secondary">
Sign it with the workspace's guest JWT key (Workspace settings Guests), carrying
<code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>,
<code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead).
</div>
<ClipboardPanel
content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)}
size="md"
/>
<div class="text-2xs text-secondary">
Replace <code>YOUR_GUEST_JWT</code> with the token your backend signs per user. Past the instance's
free guest allowance a new guest email is refused (see the count above); guests already seen
in the window keep working.
</div>
</div>
{/if}
<div class="mt-4">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">