feat(apps): isolate public apps in an iframe with a scoped embed token

WIN-2006: render public apps (and raw apps) inside an iframe handed a
narrowly-scoped token by its embedder at startup, instead of relying on
the host-based public-app route whitelist. When PUBLIC_APP_DOMAIN is set
the iframe lives on a separate origin (hard XSS boundary); otherwise it
is same-origin and the scoped token constrains the app's own calls.

Backend:
- APP_EMBED_SCOPES + mint_app_embed_token helper (apps.rs)
- GET /apps_u/embed_token/{secret} (OSS) and
  /apps_u/embed_token_by_custom_path/{custom_path} (EE) validate app
  access then mint a 12h token scoped to exactly the endpoints an app
  needs (apps:run, jobs:read, resources:read, users:read, folders:read)
- public_app_domain() getter exposing the configured domain
- unit test locking the allow/deny scope matrix

Frontend:
- PublicAppFrame.svelte: embedder (mint token, render iframe, postMessage
  handshake, login on the main domain) + viewer (use only the token)
- wired into /public/{ws}/{secret} and /a/{custom_path}

See docs/app-iframe-isolation.md. EE companion:
windmill-ee-private#ruben/win-2006-...

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-02 10:01:56 +00:00
parent 2ac198396e
commit ba5b3dca73
8 changed files with 624 additions and 67 deletions
+1 -1
View File
@@ -1 +1 @@
3742e0659c5e97aab03b9efeea14cd94a3ac658a
7dc7ce5ff98e1eb7cbed16758ac61e76542edb0b
+51
View File
@@ -7425,6 +7425,22 @@ paths:
workspace_id:
type: string
/apps_u/embed_token_by_custom_path/{custom_path}:
get:
summary: get a scoped embed token for a public app (by custom path)
operationId: getAppEmbedTokenByCustomPath
tags:
- app
parameters:
- $ref: "#/components/parameters/CustomPath"
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/scripts/hub/get/{path}:
get:
summary: get hub script content by path
@@ -10543,6 +10559,23 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps_u/embed_token/{path}:
get:
summary: get a scoped embed token for a public app (by secret)
operationId: getAppEmbedToken
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/w/{workspace}/apps_u/public_resource/{path}:
get:
summary: get public resource
@@ -26946,6 +26979,24 @@ components:
- edited_at
EmbedTokenResponse:
type: object
properties:
token:
type: string
nullable: true
description: >-
Narrowly-scoped token the embedder hands the app iframe. Null for
fully anonymous access.
public_app_domain:
type: string
nullable: true
description: Domain on which the app iframe must be served, if configured.
expiration:
type: string
format: date-time
nullable: true
AppWithLastVersion:
type: object
properties:
+186
View File
@@ -72,6 +72,7 @@ use windmill_common::{
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
use windmill_store::resources::get_resource_value_interpolated_internal;
use windmill_api_auth::{create_token_internal, NewToken};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
@@ -132,6 +133,7 @@ pub fn unauthed_service() -> Router {
.route("/delete_s3_file", delete(delete_s3_file_from_app))
.route("/download_s3_file/{*path}", get(download_s3_file_from_app))
.route("/public_app/{secret}", get(get_public_app_by_secret))
.route("/embed_token/{secret}", get(get_app_embed_token))
.route("/public_resource/{*path}", get(get_public_resource))
.route("/get_data/v/{*id}", get(get_raw_app_data))
}
@@ -886,6 +888,141 @@ async fn get_public_app_by_secret(
Ok(Json(app))
}
/// Scopes granted to a short-lived "app embed token". This is the token the
/// app-embedder page hands the app iframe at startup (WIN-2006). It is the
/// token-level replacement for the host-based `public_app_domain` route
/// whitelist: instead of restricting which routes a domain may hit, we restrict
/// which routes the *token* may hit, so that even an XSS-compromised app
/// document can only reach the endpoints an app legitimately needs.
///
/// These mirror the former public-app route whitelist:
/// - `apps:run` → `apps_u/public_app`, `get_data`, `public_resource` (read)
/// and `execute_component` (run). Note: `Run`/`Read` only —
/// it does NOT grant `apps:write`, so the token cannot reach
/// app-management routes (`apps/update`, `apps/delete`, ...).
/// - `jobs:read` → `jobs_u/getupdate_sse`, completed-job results (poll app jobs).
/// - `resources:read`→ `resources/list`, `resources/type/*`, `resources/exists`.
/// - `users:read` → `users/whoami`.
/// - `folders:read` → `folders/listnames`.
pub const APP_EMBED_SCOPES: [&str; 5] = [
"apps:run",
"jobs:read",
"resources:read",
"users:read",
"folders:read",
];
/// How long an app embed token stays valid. The embedder re-mints on demand
/// (e.g. after a `401` from the iframe) so this can stay short.
const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12;
#[derive(Serialize)]
pub struct EmbedTokenResponse {
/// Narrowly-scoped token for the iframe. `None` for fully anonymous access
/// (the iframe then calls the public endpoints anonymously).
pub token: Option<String>,
/// Domain on which the app iframe must be served, if configured.
pub public_app_domain: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
}
/// Mint a short-lived, narrowly-scoped embed token for `app_path` when a caller
/// is authenticated, and report the configured public app domain. When
/// `opt_authed` is `None` (anonymous access to an anonymous app), no token is
/// minted and the iframe relies on the public endpoints.
pub async fn mint_app_embed_token(
db: &DB,
w_id: &str,
app_path: &str,
opt_authed: Option<&ApiAuthed>,
) -> Result<EmbedTokenResponse> {
let public_app_domain = crate::public_app_layer::public_app_domain();
let token_and_exp = if let Some(authed) = opt_authed {
let expiration =
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
let scopes = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
let token_config = NewToken::new(
Some(format!("embed_app:{app_path}")),
Some(expiration),
None,
Some(scopes),
Some(w_id.to_string()),
// Never let an embed token gain write capability the caller's own
// session lacks.
Some(authed.read_only),
);
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, db, authed, token_config).await?;
tx.commit().await?;
Some((token, expiration))
} else {
None
};
Ok(EmbedTokenResponse {
token: token_and_exp.as_ref().map(|(t, _)| t.clone()),
public_app_domain,
expiration: token_and_exp.map(|(_, e)| e),
})
}
/// Issue an embed token for a public app addressed by its (secret) share id.
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
/// reachable without auth, otherwise the caller must be logged in and have read
/// access to the app.
async fn get_app_embed_token(
OptAuthed(opt_authed): OptAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
) -> JsonResult<EmbedTokenResponse> {
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
let app = sqlx::query!(
"SELECT path, policy::text as policy FROM app WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
let app = not_found_if_none(app, "App", id.to_string())?;
let policy_str = app
.policy
.ok_or_else(|| Error::internal_err("App policy missing".to_string()))?;
let policy = serde_json::from_str::<Policy>(&policy_str).map_err(to_anyhow)?;
let authed_for_token = if matches!(policy.execution_mode, ExecutionMode::Anonymous) {
// Anonymous app: still mint a scoped token if the viewer happens to be
// logged in (so the app sees their identity), otherwise stay anonymous.
opt_authed
} else {
let authed = opt_authed.ok_or_else(|| {
Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
)
})?;
let mut tx = user_db.begin(&authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
id,
&w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
Some(authed)
};
let resp = mint_app_embed_token(&db, &w_id, &app.path, authed_for_token.as_ref()).await?;
Ok(Json(resp))
}
async fn get_id_from_secret(
db: &DB,
w_id: &str,
@@ -3422,3 +3559,52 @@ async fn build_args(
job_id,
))
}
#[cfg(test)]
mod embed_token_tests {
use super::APP_EMBED_SCOPES;
use windmill_api_auth::scopes::check_scopes_for_route;
/// The embed token must reach exactly the endpoints an app needs and nothing
/// else. This locks the allow/deny matrix that replaces the host-based
/// public-app route whitelist (WIN-2006).
#[test]
fn embed_scopes_allow_app_routes_and_deny_the_rest() {
let scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
let scopes = Some(scopes.as_slice());
// Allowed: the routes a running app legitimately calls.
let allowed = [
("/api/w/test/apps_u/public_app/secret", "GET"),
("/api/w/test/apps_u/get_data/v/secret.js", "GET"),
("/api/w/test/apps_u/public_resource/f/app_themes/t", "GET"),
("/api/w/test/apps_u/execute_component/u/admin/app", "POST"),
("/api/w/test/jobs_u/getupdate_sse/some-uuid", "GET"),
("/api/w/test/users/whoami", "GET"),
("/api/w/test/resources/list", "GET"),
("/api/w/test/folders/listnames", "GET"),
];
for (path, method) in allowed {
assert!(
check_scopes_for_route(scopes, path, method).is_ok(),
"embed token should allow {method} {path}"
);
}
// Denied: anything outside what an app needs, including app management
// (apps:write is intentionally withheld) and other workspace domains.
let denied = [
("/api/w/test/apps/update/u/admin/app", "POST"),
("/api/w/test/apps/delete/u/admin/app", "DELETE"),
("/api/w/test/scripts/list", "GET"),
("/api/w/test/variables/list", "GET"),
("/api/w/test/resources/update/u/admin/r", "POST"),
];
for (path, method) in denied {
assert!(
check_scopes_for_route(scopes, path, method).is_err(),
"embed token should deny {method} {path}"
);
}
}
}
@@ -14,6 +14,18 @@ lazy_static::lazy_static! {
pub static ref PUBLIC_APP_DOMAIN: Option<String> = std::env::var("PUBLIC_APP_DOMAIN").ok();
}
/// The domain on which embedded apps must be served (in an iframe), if configured.
///
/// When set, the app-embedder page renders the app iframe with its `src` pointing
/// at this domain so that the app document lives on a separate origin and cannot
/// read the main domain's httponly session cookie. The embedder hands the iframe a
/// narrowly-scoped token via `postMessage` at startup. When unset, the iframe is
/// served same-origin (token-scoping still applies, but raw cookie-based XSS is not
/// fully prevented — see WIN-2006).
pub fn public_app_domain() -> Option<String> {
PUBLIC_APP_DOMAIN.clone()
}
/// Middleware to restrict public app domain to whitelisted routes
pub async fn public_app_domain_filter(
req: axum::extract::Request,
+100
View File
@@ -0,0 +1,100 @@
# App iframe isolation & scoped embed tokens (WIN-2006)
Published Windmill apps (and raw apps) render arbitrary, user-authored markup
and JavaScript. If that document runs on the same origin as the main Windmill
UI, an XSS payload in an app can read the httponly session cookie's privileges
(via same-origin `fetch`) and act as the logged-in user across the whole API.
To contain this, **apps are always rendered inside an iframe**, and the iframe
is handed a **narrowly-scoped token** by its embedder at startup — never the
main session cookie. When a dedicated public app domain is configured, the
iframe also lives on a **separate origin**, which turns the containment into a
hard browser-enforced boundary.
## Roles
The same public-app route plays two roles, selected by the `wm_embed=1` query
param plus whether the window is framed:
- **Embedder** (top-level window, main domain): authenticates the viewer using
the main-domain session cookie or a shared JWT, mints a scoped embed token,
and renders an `<iframe>` pointing at the same route on the iframe origin. It
hands the token to the iframe via `postMessage`. Login (for non-anonymous
apps) happens here, so the session cookie is only ever set on the main domain.
- **Viewer** (framed window, iframe origin): receives the token from the
embedder, uses it as the *only* credential for API calls, and renders the app.
```mermaid
sequenceDiagram
participant U as User
participant E as Embedder (main domain)
participant B as Backend
participant V as Viewer iframe (public_app_domain)
U->>E: GET /public/{ws}/{secret}
E->>B: GET /apps_u/embed_token/{secret} (cookie or JWT)
Note over B: validate access (anonymous / read-access),<br/>mint token scoped to APP_EMBED_SCOPES
B-->>E: { token, public_app_domain }
E->>V: render <iframe src=...?wm_embed=1&wm_embedder_origin=...>
V->>E: postMessage { wm_embed_ready }
E->>V: postMessage { wm_embed_token, token }
Note over V: OpenAPI.TOKEN = token (bearer beats cookie)
V->>B: GET public_app / execute_component / ... (scoped token only)
```
## Scoped embed token
`APP_EMBED_SCOPES` (in `backend/windmill-api/src/apps.rs`) is the token-level
replacement for the host-based `PUBLIC_APP_DOMAIN` route whitelist. Instead of
restricting which routes a *domain* may hit, it restricts which routes the
*token* may hit:
| Scope | Grants | App needs it for |
|-------|--------|------------------|
| `apps:run` | read + run on the `apps` domain (NOT write) | `public_app`, `get_data`, `public_resource`, `execute_component` |
| `jobs:read` | read jobs | `getupdate_sse`, completed-job results |
| `resources:read` | read resources | `resources/list`, `resources/type/*` |
| `users:read` | read users | `users/whoami` |
| `folders:read` | read folders | `folders/listnames` |
Because the token lacks `apps:write`, `scripts:*`, `variables:*`, `settings:*`
etc., an XSS-compromised app cannot reach app-management or any other workspace
endpoint — verified in `apps.rs` tests and manually (out-of-scope routes return
`403`). The token is minted short-lived (12h) and re-minted on demand when the
viewer reports a `401`.
For **anonymous** apps no token is minted: the iframe calls the already-public
`apps_u/*` endpoints anonymously.
## Same-origin vs. separate-origin
- **`PUBLIC_APP_DOMAIN` set** → the iframe `src` is on that domain. The app
document is cross-origin to the main UI: it cannot read the main session
cookie at all, and the scoped token is its only credential. This is the full
XSS boundary.
- **`PUBLIC_APP_DOMAIN` unset** → the iframe is same-origin. The scoped token
still constrains the app's own API calls, but a raw XSS payload could still
issue same-origin cookie-authenticated requests. This residual risk is
inherent to single-domain deployments (documented in WIN-2006); configuring a
public app domain removes it.
## Status / follow-ups
Implemented in this change:
- Backend `embed_token` endpoints (by secret in OSS `apps.rs`, by custom path in
EE `apps_ee.rs`) + `APP_EMBED_SCOPES`.
- `PublicAppFrame.svelte` embedder/viewer orchestration with the postMessage
handshake, wired into both public-app routes (`/public/{ws}/{secret}` and
`/a/{custom_path}`).
Known follow-ups:
- `Content-Security-Policy: frame-ancestors` on the viewer so only the embedder
origin can frame it (currently enforced in JS by requiring the framed state +
validated embedder origin).
- Retiring the `public_app_domain` route whitelist (`public_app_layer.rs`) once
the scoped-token path is the sole mechanism in all deployments.
- S3 upload/delete from *authenticated* embedded apps (needs `apps:write`, which
is intentionally withheld) — anonymous apps are unaffected.
- Cross-origin (`PUBLIC_APP_DOMAIN`) validation requires a second domain in the
environment; only the same-origin path has been exercised end-to-end here.
- Raw-app cross-origin-isolation (`COEP`) behaviour inside the viewer iframe.
@@ -0,0 +1,192 @@
<script lang="ts">
/*
* WIN-2006: apps and raw apps are always rendered inside an iframe so that the
* (potentially XSS-compromised) app document is isolated from the main
* Windmill session.
*
* This component plays two roles depending on whether it is the top-level
* window (the "embedder") or the framed window (the "viewer"), distinguished
* by the `wm_embed=1` query param + being framed:
*
* - Embedder: authenticates the viewer (using the main-domain session cookie
* or a JWT), mints a narrowly-scoped embed token, then renders an iframe
* pointing at the same URL (on `public_app_domain` if configured, else the
* same origin) and hands it the token via postMessage. The session cookie
* never crosses to the iframe origin.
* - Viewer: receives the token from the embedder, uses it as the *only*
* credential for API calls, and renders the actual app.
*
* When `public_app_domain` is unset the iframe is same-origin: the scoped
* token still constrains the app's own calls, but a raw XSS payload could
* still abuse the same-domain httponly cookie (documented residual risk).
*/
import { BROWSER } from 'esm-env'
import { OpenAPI } from '$lib/gen'
import { page } from '$app/state'
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
import { Alert, Skeleton } from '$lib/components/common'
import { base } from '$app/paths'
import Login from '$lib/components/Login.svelte'
type EmbedToken = { token?: string | null; public_app_domain?: string | null }
let {
fetchEmbedToken,
onViewerReady,
viewer
}: {
/** Embedder-side: validate access + mint the scoped token. Throws with a
* `.status` of 401 (login required) or 404 (not found). */
fetchEmbedToken: () => Promise<EmbedToken>
/** Viewer-side: fired (once per received token) when the embed token is
* available, before the app renders. Use it to kick off data loading.
* `requestTokenRefresh` asks the embedder for a fresh token on a 401. */
onViewerReady?: (token: string | undefined, requestTokenRefresh: () => void) => void
/** Viewer-side: renders the actual app once the embed token is available. */
viewer: Snippet
} = $props()
const EMBED_PARAM = 'wm_embed'
const ORIGIN_PARAM = 'wm_embedder_origin'
const framed = BROWSER && window.parent !== window
const isViewer = BROWSER && page.url.searchParams.get(EMBED_PARAM) === '1' && framed
// ----------------------------- viewer mode -----------------------------
let viewerToken: string | undefined = $state(undefined)
let viewerReady = $state(false)
const expectedEmbedderOrigin = BROWSER ? page.url.searchParams.get(ORIGIN_PARAM) : null
// Components that embed the token in a URL (images, PDFs, downloads) read it
// from the `AuthToken` context. In viewer mode that must be the embed token;
// the getter keeps it in sync once the token arrives.
setContext<{ token?: string }>('AuthToken', {
get token() {
return viewerToken
}
})
function handleViewerMessage(e: MessageEvent) {
if (e.source !== window.parent) return
if (expectedEmbedderOrigin && e.origin !== expectedEmbedderOrigin) return
if (e.data?.type === 'wm_embed_token') {
const token = e.data.token ?? undefined
viewerToken = token
// The bearer token (when present) takes precedence over any cookie in
// the backend auth, so this is the credential the app will use.
OpenAPI.TOKEN = token
viewerReady = true
onViewerReady?.(token, requestTokenRefresh)
}
}
/** Passed to the viewer snippet: when the rendered app gets a 401 (e.g. the
* embed token expired) it calls this to ask the embedder for a fresh token. */
function requestTokenRefresh() {
viewerReady = false
window.parent.postMessage({ type: 'wm_embed_unauthorized' }, expectedEmbedderOrigin ?? '*')
}
// ---------------------------- embedder mode ----------------------------
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' = $state('loading')
let embedToken: string | null = $state(null)
let publicAppDomain: string | null = $state(null)
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
const iframeOrigin = $derived(
publicAppDomain ? `${window.location.protocol}//${publicAppDomain}` : window.location.origin
)
function buildViewerUrl(): string {
const url = new URL(window.location.href)
url.searchParams.set(EMBED_PARAM, '1')
url.searchParams.set(ORIGIN_PARAM, window.location.origin)
// `pathname` already includes the SvelteKit base path; only swap the origin.
return iframeOrigin + url.pathname + url.search + url.hash
}
async function initEmbedder() {
status = 'loading'
try {
const resp = await fetchEmbedToken()
embedToken = resp.token ?? null
publicAppDomain = resp.public_app_domain ?? null
status = 'ready'
// If the iframe already loaded (re-mint case), push the fresh token.
postTokenToIframe()
} catch (e: any) {
status = e?.status === 401 ? 'noPermission' : 'notExists'
}
}
function postTokenToIframe() {
iframeEl?.contentWindow?.postMessage(
{ type: 'wm_embed_token', token: embedToken },
iframeOrigin
)
}
function handleEmbedderMessage(e: MessageEvent) {
if (e.source !== iframeEl?.contentWindow) return
if (e.origin !== iframeOrigin) return
if (e.data?.type === 'wm_embed_ready') {
postTokenToIframe()
} else if (e.data?.type === 'wm_embed_unauthorized') {
initEmbedder()
}
}
onMount(() => {
if (isViewer) {
window.addEventListener('message', handleViewerMessage)
// Announce readiness so the embedder sends us the token.
window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*')
} else {
window.addEventListener('message', handleEmbedderMessage)
initEmbedder()
}
})
onDestroy(() => {
if (!BROWSER) return
window.removeEventListener('message', handleViewerMessage)
window.removeEventListener('message', handleEmbedderMessage)
})
</script>
{#if isViewer}
{#if viewerReady}
{@render viewer()}
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
{/if}
{:else if status === 'loading'}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if status === 'notExists'}
<div class="px-4 mt-20">
<Alert type="error" title="Not found">
There was an error loading the app, is the url correct?
<a href={base}>Go to Windmill</a>
</Alert>
</div>
{:else if status === 'noPermission'}
<!-- Login happens here, on the main (embedder) domain, so the session cookie
is set on the main domain only and never reaches the iframe origin. -->
<div class="px-4 mt-20 w-full text-center font-bold text-xl">This app requires read access</div>
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
<Login
onLoginSuccess={() => initEmbedder()}
popup
rd={page.url.pathname + page.url.search + page.url.hash}
/>
</div>
{:else}
<iframe
bind:this={iframeEl}
src={buildViewerUrl()}
title="App"
class="w-full h-screen border-0 block"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"
allow="clipboard-read; clipboard-write; fullscreen"
></iframe>
{/if}
+39 -25
View File
@@ -4,18 +4,19 @@
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { setContext } from 'svelte'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined =
$state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let jwtError = $state(false)
let workspace: string | undefined = $state(undefined)
function isJwt(t: string) {
// simply check that the first part is a valid base64 encoded json
@@ -44,37 +45,43 @@
}
}
let workspace: string | undefined = $state(undefined)
async function loadApp() {
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
let refresh: (() => void) | undefined
// Embedder side: validate access + mint a scoped token for the iframe.
async function fetchEmbedToken() {
if (parsedCustomPath.jwt) {
const token = 'jwt_ext_' + parsedCustomPath.jwt
OpenAPI.TOKEN = token
setContext<{ token?: string }>('AuthToken', { token })
jwtError = false
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
}
return await AppService.getAppEmbedTokenByCustomPath({
customPath: parsedCustomPath.path
})
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
try {
app = await AppService.getPublicAppByCustomPath({
customPath: parsedCustomPath.path
})
workspace = app.workspace_id
workspaceStore.set(app.workspace_id)
if (app.workspace_id) {
workspaceStore.set(app.workspace_id)
}
noPermission = false
notExists = false
try {
userStore.set(await getUserExt(app.workspace_id))
if (!$userStore && parsedCustomPath.jwt) {
jwtError = true
sendUserToast('Could not authentify user with jwt token', true)
if (app.workspace_id) {
userStore.set(await getUserExt(app.workspace_id))
}
} catch (e) {
console.warn('Anonymous user')
}
} catch (e) {
if (e.status == 401) {
noPermission = true
refresh?.()
} else {
notExists = true
}
@@ -83,17 +90,24 @@
if (BROWSER) {
setLicense()
loadApp()
}
</script>
<PublicApp
{workspace}
{notExists}
{noPermission}
{jwtError}
{app}
onLoginSuccess={() => {
<PublicAppFrame
{fetchEmbedToken}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
></PublicApp>
>
{#snippet viewer()}
<PublicApp
{workspace}
{notExists}
{noPermission}
{jwtError}
{app}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
@@ -4,21 +4,19 @@
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { userStore } from '$lib/stores'
import { setContext } from 'svelte'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let jwtError = $state(false)
function parseSecret(secret: string): { secret: string; jwt: string } {
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
const parts = secret.split('/')
return {
secret: parts[0],
@@ -27,18 +25,40 @@
}
const parsedSecret = parseSecret(page.params.secret ?? '')
const workspace = page.params.workspace ?? ''
let refresh: (() => void) | undefined
// Embedder side: validate access (using the main-domain session cookie or the
// shared JWT) and mint a scoped token for the iframe.
async function fetchEmbedToken() {
if (parsedSecret.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
}
return await AppService.getAppEmbedToken({
workspace,
path: parsedSecret.secret
})
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
app = await AppService.getPublicAppBySecret({
workspace: page.params.workspace ?? '',
workspace,
path: parsedSecret.secret
})
noPermission = false
notExists = false
} catch (e) {
if (e.status == 401) {
noPermission = true
// Embed token missing/expired — ask the embedder for a fresh one.
refresh?.()
} else {
notExists = true
}
@@ -47,42 +67,24 @@
if (BROWSER) {
setLicense()
loadAll()
}
function loadAll() {
console.log('loadAll')
loadUser().then(() => {
loadApp()
})
}
async function loadUser() {
if (parsedSecret.jwt) {
const token = 'jwt_ext_' + parsedSecret.jwt
OpenAPI.TOKEN = token
setContext<{ token?: string }>('AuthToken', { token })
jwtError = false
}
try {
userStore.set(await getUserExt(page.params.workspace ?? ''))
if (!$userStore && parsedSecret.jwt) {
jwtError = true
sendUserToast('Could not authentify user with jwt token', true)
}
} catch (e) {
console.warn('Anonymous user')
}
}
</script>
<PublicApp
{app}
workspace={page.params.workspace}
{notExists}
{noPermission}
{jwtError}
onLoginSuccess={() => {
loadAll()
<PublicAppFrame
{fetchEmbedToken}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
></PublicApp>
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
{jwtError}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>