mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
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:
@@ -1 +1 @@
|
||||
3742e0659c5e97aab03b9efeea14cd94a3ac658a
|
||||
7dc7ce5ff98e1eb7cbed16758ac61e76542edb0b
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user