feat: guest JWT entry for embedded apps

A second way in for a guest (companion to windmill#10929): a JWT the embedding
customer's own backend mints and signs, carried on the app's share link and
verified against a key the workspace admin configured. It needs no
identity-provider round-trip, so it works inside an iframe where popups and
third-party cookies do not. Bearer prefix jwt_guest_, stateless: verified per
request, cached until exp, no token row.

A JWT guest is the same identity as a signed-in guest: no usr row, no password
row, no seat, confined to the one app app_path names. Every guest gate applies:
the plan, the workspace switch (enforced once at the auth door via the sentinel),
the app mode (guest_app_admits), and "no account at all" (has_any_account). The
claim's workspace_id must equal the route's workspace, and a workspace-less route
never accepts it.

Claims honoured: email, workspace_id, app_path, exp (mandatory); nbf/iat
validated when present; the accepted lifetime is capped at 24h. Algorithms:
RS256/384/512, PS256/384/512, ES256/384; HS* is refused. The key is a
per-workspace setting, a PEM public key or a JWKS URL (at most one, a DB CHECK
enforces it), Enterprise-plan gated like the guest switch. The JWKS URL is
validated against private ranges and the fetch is pinned to the validated
address.

Counting: a JWT guest is recorded in guest_activity (once per email, workspace
and day, cached), marked jwt_entry, and not in unique_ext_jwt_token. A first-seen
users.login_guest audit carries the entry kind.

Narrower than jwt_ext_ by design: that key is instance-level and can assert
admin, groups and folders; a guest key is scoped to one workspace and only ever
mints guests. An app-only user a customer routes through jwt_ext_ today is
counted; through a guest JWT they become a free guest, the intended pricing
change, split out as guest_jwt_count in the telemetry so it can be measured.

Changes on the parent branch, additive: ApiAuthed.credential_expiry (a
credential's own expiry when it has no token row); guest_derived_token_constraints
caps on it; guest_session_scopes moved to windmill-api-auth::scopes and
has_any_account to windmill-common::users so the mint and the JWT arm share one
copy; the signed-in mint's login_guest audit now carries entry=idp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
This commit is contained in:
Ruben Fiszel
2026-09-03 08:00:25 +00:00
co-authored by Claude Opus 4.8
parent 7dd5ccd374
commit 9cb8e991eb
33 changed files with 1257 additions and 74 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
"describe": {
"columns": [
{
@@ -15,16 +15,21 @@
},
{
"ordinal": 2,
"name": "guest_workspace_count!",
"name": "guest_jwt_count!",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "guest_enabled_workspace_count!",
"name": "guest_workspace_count!",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "guest_enabled_workspace_count!",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "workspace_count!",
"type_info": "Int4"
}
@@ -37,8 +42,9 @@
null,
null,
null,
null,
null
]
},
"hash": "8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60"
"hash": "06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -167,6 +167,16 @@
"ordinal": 32,
"name": "guest_access_enabled",
"type_info": "Bool"
},
{
"ordinal": 33,
"name": "guest_jwt_public_key",
"type_info": "Text"
},
{
"ordinal": 34,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
}
],
"parameters": {
@@ -207,8 +217,10 @@
true,
true,
false,
false
false,
true,
true
]
},
"hash": "00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2"
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "guest_jwt_public_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f6fe63ef3518d2d1f321c2941bc59da15843f466c72159bf76712b124d7554b6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO guest_activity (email, workspace_id, day, jwt_entry)\n VALUES ($1, $2, CURRENT_DATE, true)\n ON CONFLICT (email, workspace_id, day)\n DO UPDATE SET jwt_entry = true, last_seen_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "f893c06e7eb4c1c66e55f8fc477c7d4e5e94889ef5e72b4961ba4838e79bfffa"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "fbee9545c564f6fd611ca1a122cf420b03fd23304f78c382d6de14cb31bcd6b1"
}
+1
View File
@@ -14763,6 +14763,7 @@ dependencies = [
"git-version",
"hex",
"hmac",
"jsonwebtoken 8.3.0",
"lazy_static",
"once_cell",
"opentelemetry 0.30.0",
+1
View File
@@ -367,6 +367,7 @@ aws-config.workspace = true
aws-credential-types.workspace = true
hmac.workspace = true
hex.workspace = true
jsonwebtoken = { workspace = true }
[workspace.dependencies]
+1 -1
View File
@@ -1 +1 @@
f7853642aba08173dba3fc38c5c8a4036efa9bde
fa32c1374f3e8c57ec8d7e7171b943d18b888361
@@ -0,0 +1,5 @@
ALTER TABLE guest_activity DROP COLUMN jwt_entry;
ALTER TABLE workspace_settings
DROP CONSTRAINT workspace_settings_guest_jwt_one_key,
DROP COLUMN guest_jwt_public_key,
DROP COLUMN guest_jwt_jwks_url;
@@ -0,0 +1,15 @@
-- A second way in for a guest: a JWT minted by the embedding customer's own backend and
-- verified against a key the workspace admin configured. One key shape per workspace,
-- a PEM public key or a JWKS URL, never both: a token is verified against exactly one
-- source, and two would make "which one refused it" undiagnosable.
ALTER TABLE workspace_settings
ADD COLUMN guest_jwt_public_key TEXT,
ADD COLUMN guest_jwt_jwks_url TEXT,
ADD CONSTRAINT workspace_settings_guest_jwt_one_key
CHECK (guest_jwt_public_key IS NULL OR guest_jwt_jwks_url IS NULL);
-- Whether the guest came in on a JWT that day (as opposed to, or as well as, an
-- identity-provider sign-in). The seat telemetry reports the two entries apart, since
-- an app-only user routed through a guest JWT is one that `jwt_ext_` would have counted.
ALTER TABLE guest_activity
ADD COLUMN jwt_entry BOOLEAN NOT NULL DEFAULT false;
+412
View File
@@ -0,0 +1,412 @@
//! Tests for the guest JWT entry: a guest that enters through a JWT the embedding
//! customer's own backend mints and signs, with no identity-provider round-trip.
//!
//! The key is a per-workspace setting (a PEM public key here), and the token is
//! verified per request against it. A JWT guest is the same identity as a signed-in
//! guest: no `usr` row, no `password` row, no seat, confined to the one app its
//! `app_path` names. These tests pin what a token must carry to be honoured, and the
//! refusals that keep the door narrow: wrong workspace, wrong key, expired, a
//! symmetric algorithm, an email that already has an account, an app not in guest
//! mode, and the workspace switch off.
//!
//! The keys are fixed test vectors (EC P-256, PKCS8), so signing is deterministic and
//! needs no key generation at runtime.
// The plan gate refuses every guest on a build without these; CI builds with them.
#![cfg(all(feature = "enterprise", feature = "private"))]
use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
const APP_PATH: &str = "u/test-user/guest_app";
const GUEST_EMAIL: &str = "guest@example.com";
// A P-256 keypair the workspace verifies against (PUB1), and a second private key
// (PRIV2) that it does not, for the wrong-key refusal.
const PRIV1: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgu27S2DbSwUh8BmQb\n/i4/VhNdoXV7PJekhnoceMULYLihRANCAATMB+rIKHfiJg4JbS+Dh6Or/PMmXMtJ\nlJyOdXI+MsZNqkTCjiBzr/LVi513+/njAqHQ519N/MAow9bH/Y1bH+6C\n-----END PRIVATE KEY-----\n";
const PUB1: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzAfqyCh34iYOCW0vg4ejq/zzJlzL\nSZScjnVyPjLGTapEwo4gc6/y1Yudd/v54wKh0OdfTfzAKMPWx/2NWx/ugg==\n-----END PUBLIC KEY-----\n";
const PRIV2: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjyhWYyI2+z5zTT0B\neI9EuJJ7v0tcNXhvHrq9y2AG1LihRANCAAS40dEdO+tTffhGt4YQv0dStkd6VcWN\n+CHI9QqZAHAJMsNS3Ld+sZe2M6Of0CNR300QJtfp4UIdEVbXBCIxL1D0\n-----END PRIVATE KEY-----\n";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[derive(Serialize)]
struct Claims {
email: String,
workspace_id: String,
app_path: String,
exp: u64,
#[serde(skip_serializing_if = "Option::is_none")]
nbf: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
iat: Option<u64>,
}
impl Claims {
fn valid() -> Self {
Claims {
email: GUEST_EMAIL.to_string(),
workspace_id: "test-workspace".to_string(),
app_path: APP_PATH.to_string(),
exp: now() + 3600,
nbf: None,
iat: None,
}
}
}
/// Sign as a bearer (`jwt_guest_<jwt>`). `priv_pem`/`alg` let a test sign with the
/// wrong key or a refused algorithm.
fn bearer(claims: &Claims, priv_pem: &str, alg: Algorithm) -> String {
let key = match alg {
Algorithm::HS256 => EncodingKey::from_secret(b"a-shared-secret"),
_ => EncodingKey::from_ec_pem(priv_pem.as_bytes()).unwrap(),
};
let jwt = encode(&Header::new(alg), claims, &key).unwrap();
format!("jwt_guest_{jwt}")
}
async fn enable_guests(port: u16, ws: &str, on: bool) -> anyhow::Result<()> {
authed(
client().post(format!(
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access"
)),
ADMIN_TOKEN,
)
.json(&json!({ "guest_access_enabled": on }))
.send()
.await?;
Ok(())
}
async fn set_guest_jwt_pem(port: u16, ws: &str, pem: &str) -> anyhow::Result<()> {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_jwt_key"
)),
ADMIN_TOKEN,
)
.json(&json!({ "public_key": pem }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
fn app(path: &str, execution_mode: &str, sandbox: bool) -> serde_json::Value {
json!({
"path": path,
"summary": "App",
"value": {},
"policy": {
"execution_mode": execution_mode,
"sandbox": sandbox,
"triggerables_v2": {
"script/u/test-user/noop": { "static_inputs": {}, "one_of_inputs": {} }
}
}
})
}
async fn create_app(port: u16, ws: &str, v: serde_json::Value) -> anyhow::Result<()> {
let resp = authed(
client().post(format!("http://localhost:{port}/api/w/{ws}/apps/create")),
ADMIN_TOKEN,
)
.json(&v)
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
Ok(())
}
fn whoami(port: u16, ws: &str, token: &str) -> reqwest::RequestBuilder {
authed(
client().get(format!("http://localhost:{port}/api/w/{ws}/users/whoami")),
token,
)
}
/// A valid guest JWT opens its app, runs a component as the publisher, reads the run
/// back, reports `role: guest`, and leaves exactly one `guest_activity` row however
/// many requests it makes.
#[sqlx::test(fixtures("base"))]
async fn a_valid_guest_jwt_opens_its_app(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
let resp = authed(
client().post(format!("http://localhost:{port}/api/w/{ws}/scripts/create")),
ADMIN_TOKEN,
)
.json(&json!({
"path": "u/test-user/noop",
"summary": "",
"description": "",
"content": "echo 42",
"language": "bash",
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
// A distinct email: the activity write is deduplicated by a process-global cache
// keyed on email, workspace and day, and other tests in this binary share the
// guest email, so the count below is only this test's if its email is its own.
let mut claims = Claims::valid();
claims.email = "activity-guest@example.com".to_string();
let token = bearer(&claims, PRIV1, Algorithm::ES256);
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(resp.status(), 200, "guest JWT must authenticate");
let me: serde_json::Value = resp.json().await?;
assert_eq!(me["role"], json!("guest"), "must read as a guest");
assert_eq!(me["operator"], json!(true));
assert_eq!(me["is_admin"], json!(false));
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/{ws}/apps_u/execute_component/{APP_PATH}"
)),
&token,
)
.json(&json!({ "component": "a", "path": "script/u/test-user/noop", "args": {} }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let job_id = resp.text().await?;
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/{ws}/jobs_u/getupdate/{job_id}"
)),
&token,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"the guest that started the run must read it back: {}",
resp.text().await?
);
// Several requests, one row: the write is cached per email, workspace and day.
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM guest_activity WHERE email = $1 AND workspace_id = $2 AND jwt_entry",
)
.bind(&claims.email)
.bind(ws)
.fetch_one(&db)
.await?;
assert_eq!(count, 1, "a JWT guest must leave exactly one activity row");
Ok(())
}
/// The refusals that keep the door narrow. Each presents a bearer on the workspace's
/// own `whoami`, which the arm reaches only after every gate, so a 401 is the arm
/// saying no rather than a handler.
#[sqlx::test(fixtures("base"))]
async fn guest_jwt_refusals(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
create_app(port, ws, app("u/test-user/members_app", "publisher", false)).await?;
// wrong workspace: the claim must name the route's workspace.
let mut c = Claims::valid();
c.workspace_id = "other-ws".to_string();
let wrong_ws = bearer(&c, PRIV1, Algorithm::ES256);
// wrong key: signed with a key the workspace does not hold.
let wrong_key = bearer(&Claims::valid(), PRIV2, Algorithm::ES256);
// expired, past the verifier's clock-skew leeway.
let mut c = Claims::valid();
c.exp = now() - 120;
let expired = bearer(&c, PRIV1, Algorithm::ES256);
// a symmetric algorithm is never accepted.
let hs256 = bearer(&Claims::valid(), PRIV1, Algorithm::HS256);
// an email that already has an account is refused, not downgraded.
let mut c = Claims::valid();
c.email = "test@windmill.dev".to_string();
let has_account = bearer(&c, PRIV1, Algorithm::ES256);
// an app not in guest mode.
let mut c = Claims::valid();
c.app_path = "u/test-user/members_app".to_string();
let not_guest_app = bearer(&c, PRIV1, Algorithm::ES256);
for (label, token) in [
("wrong workspace", wrong_ws),
("wrong key", wrong_key),
("expired", expired),
("HS256", hs256),
("email with an account", has_account),
("app not in guest mode", not_guest_app),
] {
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(resp.status(), 401, "{label} must be refused");
}
Ok(())
}
/// The workspace switch gates a JWT guest exactly as it gates a signed-in one, at the
/// auth door, so turning guests off closes the JWT entry too.
#[sqlx::test(fixtures("base"))]
async fn guest_jwt_needs_the_workspace_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256);
// Switch off (the default): refused.
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(
resp.status(),
401,
"a JWT guest must be refused while guests are off"
);
// Switch on: through.
enable_guests(port, ws, true).await?;
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(
resp.status(),
200,
"with guests on, the JWT guest is admitted"
);
// Off again: closed on the next request.
enable_guests(port, ws, false).await?;
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(
resp.status(),
401,
"turning guests off closes the JWT guest again"
);
Ok(())
}
/// A guest JWT is pinned to the workspace its claim names, so it authenticates on no
/// workspace-less route: the arm has no workspace to check the claim against.
#[sqlx::test(fixtures("base"))]
async fn guest_jwt_rejected_on_workspaceless_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256);
let resp = authed(
client().get(format!("http://localhost:{port}/api/users/tokens/list")),
&token,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"a guest JWT must not authenticate on a workspace-less route"
);
Ok(())
}
/// An embed token a JWT guest mints for a sandboxed app is capped at the JWT's own
/// expiry: a JWT has no token row, so the cap is carried through the auth cache. It
/// must not outlive the JWT, which is the guest's only revocation.
#[sqlx::test(fixtures("base"))]
async fn a_guest_jwt_derived_embed_token_is_capped(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", true)).await?;
let secret: String = authed(
client().get(format!(
"http://localhost:{port}/api/w/{ws}/apps/secret_of/{APP_PATH}"
)),
ADMIN_TOKEN,
)
.send()
.await?
.text()
.await?;
let claims = Claims::valid();
let jwt_exp = claims.exp;
let token = bearer(&claims, PRIV1, Algorithm::ES256);
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/{ws}/apps_u/embed_token/{secret}"
)),
&token,
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let body: serde_json::Value = resp.json().await?;
let child_exp: chrono::DateTime<chrono::Utc> = body["expiration"]
.as_str()
.and_then(|e| e.parse().ok())
.expect("mint must return the token's expiration");
assert!(
child_exp.timestamp() as u64 <= jwt_exp,
"the derived embed token ({child_exp}) must not outlive the JWT (exp {jwt_exp})"
);
// And it resolves as a guest.
let embed = body["token"].as_str().expect("mint must return a token");
let resp = whoami(port, ws, embed).send().await?;
assert_eq!(resp.status(), 200);
let me: serde_json::Value = resp.json().await?;
assert_eq!(me["role"], json!("guest"));
Ok(())
}
+1
View File
@@ -25,6 +25,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
@@ -178,6 +178,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+1
View File
@@ -1094,6 +1094,7 @@ async fn test_privilege_gates_reject_a_job_token_directly(
token_prefix: None,
read_only: false,
job_id,
credential_expiry: None,
}
}
@@ -33,6 +33,7 @@ fn outsider() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+134
View File
@@ -215,6 +215,74 @@ impl AuthCache {
None
}
}
_ if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX) => {
// A workspace-less route never accepts a guest JWT: the identity is
// pinned to the workspace its claim names, like a DB guest session.
let Some(w_id) = w_id.as_deref() else { return None };
let jwt = token.trim_start_matches(windmill_common::guest_jwt::BEARER_PREFIX);
let claims =
match windmill_common::guest_jwt::verify_for_workspace(&self.db, w_id, jwt).await
{
Ok(c) => c,
Err(e) => {
tracing::error!("guest JWT auth error for {w_id}: {e:#}");
return None;
}
};
// Every gate a signed-in guest passes: the plan, the workspace switch and
// the app being in guest mode, in one answer. The switch is re-read at the
// auth door on every request through the sentinel below, so turning guests
// off stops a cached JWT session on its next call.
match windmill_common::workspaces::guest_app_admits(&self.db, w_id, &claims.app_path)
.await
{
Ok(true) => {}
Ok(false) => return None,
Err(e) => {
tracing::error!("guest JWT admit check failed for {w_id}: {e:#}");
return None;
}
}
// A guest is someone with no account at all; an account holder is refused,
// never downgraded (the same rule as the signed-in guest mint).
match windmill_common::users::has_any_account(&self.db, &claims.email).await {
Ok(false) => {}
Ok(true) => return None,
Err(e) => {
tracing::error!("guest JWT account check failed: {e:#}");
return None;
}
}
record_guest_jwt_activity(&self.db, w_id, &claims).await;
let expiry = chrono::Utc.timestamp_nanos(claims.exp as i64 * 1_000_000_000);
// The label alone makes a DB token a guest; a JWT has none, so the sentinel
// is what governs it, exactly as it governs a guest-derived token.
let scopes = Some(crate::scopes::with_guest_sentinel(
crate::scopes::guest_session_scopes(&claims.app_path),
));
let authed = ApiAuthed {
username: claims.email.clone(),
email: claims.email,
is_admin: false,
is_operator: true,
groups: vec![],
folders: vec![],
scopes,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
job_id: None,
// Carried to the derived-token mint: its expiry caps on this.
credential_expiry: Some(expiry),
};
AUTH_CACHE.insert(
key,
ExpiringAuthCache { authed: authed.clone(), expiry, job_id: None },
);
Some(OptJobAuthed { authed, job_id: None })
}
_ if token.starts_with("jwt_") => {
let jwt_token = token.trim_start_matches("jwt_");
@@ -248,6 +316,7 @@ impl AuthCache {
token_prefix: claims.audit_span,
read_only: false,
job_id: None,
credential_expiry: None,
};
// Fail closed: a `job_id` claim that does not parse must reject
// the token rather than resolve to `None`, which would clear the
@@ -362,6 +431,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
} else {
tracing::warn!(
@@ -415,6 +485,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
} else {
tracing::warn!(
@@ -493,6 +564,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
None if super_admin => {
@@ -517,6 +589,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
}),
Err(e) => {
tracing::error!(
@@ -554,6 +627,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
None => None,
@@ -573,6 +647,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
}
@@ -611,6 +686,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
job_id: None,
credential_expiry: None,
};
Some(OptJobAuthed { authed, job_id: None })
} else {
@@ -621,6 +697,63 @@ impl AuthCache {
}
}
lazy_static::lazy_static! {
// One `guest_activity` upsert and one `users.login_guest` audit per email,
// workspace and day: a guest JWT is a bearer sent on every call, and neither the
// seat scan nor the audit trail wants one row per request. LRU-bounded; the day is
// in the key, so a new day writes again.
static ref GUEST_JWT_ACTIVITY_CACHE: Cache<String, ()> = Cache::new(2000);
}
/// Record that a JWT guest was seen today (the only durable trace of a guest, since it
/// leaves no `usr` row), and audit the login the first time. `jwt_entry` marks the row
/// so the seat telemetry can tell a JWT guest from a signed-in one. Idempotent and
/// cached, so a bearer replayed every request writes at most once a day.
async fn record_guest_jwt_activity(
db: &DB,
w_id: &str,
claims: &windmill_common::guest_jwt::GuestJwtClaims,
) {
let cache_key = format!("{}|{w_id}|{}", claims.email, chrono::Utc::now().date_naive());
if GUEST_JWT_ACTIVITY_CACHE.get(&cache_key).is_some() {
return;
}
if let Err(e) = sqlx::query!(
"INSERT INTO guest_activity (email, workspace_id, day, jwt_entry)
VALUES ($1, $2, CURRENT_DATE, true)
ON CONFLICT (email, workspace_id, day)
DO UPDATE SET jwt_entry = true, last_seen_at = now()",
claims.email,
w_id,
)
.execute(db)
.await
{
tracing::error!("recording guest JWT activity for {w_id}: {e:#}");
return;
}
let author = windmill_common::audit::AuditAuthor {
email: claims.email.clone(),
username: claims.email.clone(),
username_override: None,
token_prefix: None,
};
if let Err(e) = windmill_audit::audit_oss::audit_log(
db,
&author,
"users.login_guest",
windmill_audit::ActionKind::Create,
w_id,
Some(claims.app_path.as_str()),
Some([("entry", "jwt")].into()),
)
.await
{
tracing::error!("auditing guest JWT login for {w_id}: {e:#}");
}
GUEST_JWT_ACTIVITY_CACHE.insert(cache_key, ());
}
pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
@@ -821,6 +954,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+7
View File
@@ -78,6 +78,11 @@ pub struct ApiAuthed {
/// member can point at a superadmin, so it must never be trusted as a global
/// superadmin (`require_super_admin`), GHSA-hfh4-cx4h-3fcr.
pub job_id: Option<uuid::Uuid>,
/// When this credential itself expires, if it carries its own expiry rather than a
/// token row. Set for a guest JWT (its `exp`): a token minted from it is capped at
/// this, since the JWT's expiry is a guest's only revocation and there is no row to
/// look the limit up in. `None` for every credential whose limit lives in `token`.
pub credential_expiry: Option<chrono::DateTime<chrono::Utc>>,
}
impl ApiAuthed {
@@ -165,6 +170,7 @@ impl From<Authed> for ApiAuthed {
token_prefix: value.token_prefix,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
}
@@ -1074,6 +1080,7 @@ pub async fn fetch_api_authed_from_permissioned_as(
token_prefix: authed.token_prefix,
read_only: false,
job_id: None,
credential_expiry: None,
};
API_AUTHED_CACHE.insert(
+16
View File
@@ -793,6 +793,22 @@ pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
scopes
}
/// Scopes a guest session carries. The broad-looking reads are narrowed to a route
/// allowlist by the sentinel (`guest_route_denied`), plus the two path-scoped app
/// grants. A guest has no `usr` row, so this list is the whole of what it can do. The
/// single source both the mint (a signed-in guest) and the JWT auth arm build from.
pub fn guest_session_scopes(app_path: &str) -> Vec<String> {
vec![
GUEST_SENTINEL.to_string(),
"jobs:read".to_string(),
"resources:run".to_string(),
"users:read".to_string(),
"folders:read".to_string(),
format!("apps:read:{app_path}"),
format!("apps:run:{app_path}"),
]
}
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
/// to narrow the declared scopes to what the viewer's prompt promised.
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
@@ -63,6 +63,7 @@ fn test_authed() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+5 -32
View File
@@ -2937,24 +2937,6 @@ lazy_static::lazy_static! {
.unwrap_or(8 * 60 * 60);
}
/// Scopes a guest session carries. Mirrors `APP_EMBED_SCOPES` — the same broad-looking
/// reads narrowed to a route allowlist by the sentinel (`guest_route_denied`) — plus the
/// two path-scoped app grants minted per app. A guest has no `usr` row, so this list is
/// the whole of what it can do.
///
/// The `guest` sentinel here only narrows. What makes the session a guest at all is the
/// server-minted label ([`windmill_common::auth::GUEST_SESSION_LABEL`]).
fn guest_session_scopes(app_path: &str) -> Vec<String> {
vec![
windmill_api_auth::scopes::GUEST_SENTINEL.to_string(),
"jobs:read".to_string(),
"resources:run".to_string(),
"users:read".to_string(),
"folders:read".to_string(),
format!("apps:read:{app_path}"),
format!("apps:run:{app_path}"),
]
}
/// Mint a browser session for someone the identity provider authenticated who is a
/// member of no workspace, so they can open one guest-mode app. Writes no `password`
@@ -2986,20 +2968,11 @@ pub async fn create_guest_session_token<'c>(
} else {
Some(&token)
};
let scopes = guest_session_scopes(app_path);
let scopes = windmill_api_auth::scopes::guest_session_scopes(app_path);
// A guest is someone with no account at all — no `password` row (deactivated ones
// included: the sign-in path's own lookup filters on `disabled = false`, so a
// SCIM-offboarded account reads as absent there) and no `usr` row anywhere, which
// is what a service account has instead of a password.
let has_account: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
)
.bind(email)
.fetch_one(&mut **tx)
.await?;
if has_account {
// A guest is someone with no account at all (see `has_any_account`); an account
// holder is refused a guest session, never handed a second, cheaper identity.
if windmill_common::users::has_any_account(&mut **tx, email).await? {
return Err(Error::NotAuthorized(
"an existing account cannot hold a guest session".to_string(),
));
@@ -3052,7 +3025,7 @@ pub async fn create_guest_session_token<'c>(
ActionKind::Create,
w_id,
Some(app_path),
None,
Some([("entry", "idp")].into()),
)
.await?;
@@ -151,6 +151,7 @@ pub fn workspaced_service() -> Router {
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
.route("/edit_default_app", post(edit_default_app))
.route("/edit_guest_access", post(edit_guest_access))
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
.route("/default_app", get(get_default_app))
.route(
"/default_scripts",
@@ -321,6 +322,13 @@ pub struct WorkspaceSettings {
/// authenticated who is a member of nothing, and who therefore takes no seat. An
/// app's own `execution_mode: guest` is inert while this is off.
pub guest_access_enabled: bool,
/// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most
/// one (a DB CHECK enforces it). Public material, not a secret, so it is admin-
/// readable here. `None`/`None` means the workspace mints no guests from a JWT.
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_jwks_url: Option<String>,
}
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
@@ -1081,7 +1089,9 @@ async fn get_settings(
success_handler,
public_app_execution_limit_per_minute,
error_handler_fallback_to_instance_alerts,
guest_access_enabled
guest_access_enabled,
guest_jwt_public_key,
guest_jwt_jwks_url
FROM
workspace_settings
WHERE
@@ -4659,6 +4669,69 @@ async fn edit_guest_access(
))
}
#[derive(Deserialize)]
struct EditGuestJwtKey {
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty
/// clears the key, after which the workspace mints no guest from a JWT.
public_key: Option<String>,
jwks_url: Option<String>,
}
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
/// EE and Enterprise-plan gated like the guest switch. The key is validated before it
/// is stored so a typo is refused here, not silently on every guest later: a PEM must
/// parse as an RS/ES public key (HS* has no PEM form and is unreachable), and a JWKS
/// URL must be fetchable and hold at least one usable signing key.
async fn edit_guest_jwt_key(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(EditGuestJwtKey { public_key, jwks_url }): Json<EditGuestJwtKey>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
if !windmill_common::workspaces::guest_access_licensed().await {
return Err(Error::BadRequest(
"Guest access requires an Enterprise license".to_string(),
));
}
let public_key = public_key.filter(|s| !s.trim().is_empty());
let jwks_url = jwks_url.filter(|s| !s.trim().is_empty());
if public_key.is_some() && jwks_url.is_some() {
return Err(Error::BadRequest(
"Set a PEM public key or a JWKS URL, not both".to_string(),
));
}
if let Some(pem) = public_key.as_deref() {
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
}
if let Some(url) = jwks_url.as_deref() {
windmill_common::guest_jwt::fetch_jwks(url).await?;
}
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
public_key,
jwks_url,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_guest_jwt_key",
ActionKind::Update,
&w_id,
None,
None,
)
.await?;
tx.commit().await?;
Ok(format!("Guest JWT key updated for workspace {w_id}"))
}
async fn edit_default_scripts(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -11161,6 +11234,7 @@ async fn load_workspace_authed(
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
job_id: base_authed.job_id,
credential_expiry: base_authed.credential_expiry,
});
};
@@ -11193,6 +11267,7 @@ async fn load_workspace_authed(
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
job_id: base_authed.job_id,
credential_expiry: base_authed.credential_expiry,
})
}
+41
View File
@@ -3757,6 +3757,12 @@ paths:
guest_access_enabled:
type: boolean
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
guest_jwt_public_key:
type: string
description: PEM public key a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_jwks_url`.
guest_jwt_jwks_url:
type: string
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -5780,6 +5786,41 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_jwt_key:
post:
summary: set the key guest JWTs are verified against for this workspace
description: >-
A guest JWT (`jwt_guest_`) is minted by the embedding customer's own backend and
verified against this key: a PEM public key (RS/ES family, HS* refused) or a JWKS
URL, at most one. Both empty clears the key. Enterprise-plan and workspace-admin
gated like the guest switch. The key is validated before it is stored.
operationId: editGuestJwtKey
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: The guest JWT verification key
required: true
content:
application/json:
schema:
type: object
properties:
public_key:
type: string
description: A PEM public key (RS or ES family).
jwks_url:
type: string
description: A JWKS URL whose keys are fetched and refreshed.
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/default_scripts:
post:
summary: edit default scripts for workspace
+18 -14
View File
@@ -1504,20 +1504,24 @@ async fn guest_derived_token_constraints(
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
return Ok(None);
}
// The minter is known by prefix only; MIN over a (theoretical) prefix collision is
// the conservative side.
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
)
.bind(authed.token_prefix.as_deref().unwrap_or(""))
.bind(&authed.email)
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
.fetch_optional(db)
.await?;
let Some(parent_exp) = parent.flatten() else {
return Err(Error::NotAuthorized(
"guest session not found or has no expiry".to_string(),
));
// A guest JWT carries its own expiry and has no token row to look up; a signed-in
// guest session is a row found by prefix (MIN is the conservative side of a
// theoretical prefix collision). Either way the derived token caps on it, never on
// a fresh interval.
let parent_exp = if let Some(exp) = authed.credential_expiry {
exp
} else {
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
)
.bind(authed.token_prefix.as_deref().unwrap_or(""))
.bind(&authed.email)
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
.fetch_optional(db)
.await?;
parent.flatten().ok_or_else(|| {
Error::NotAuthorized("guest session not found or has no expiry".to_string())
})?
};
Ok(Some((
windmill_common::auth::GUEST_SESSION_LABEL.to_string(),
+1
View File
@@ -11618,6 +11618,7 @@ mod approval_view_gate_tests {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+1
View File
@@ -378,6 +378,7 @@ async fn inject_agent_authed(
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
},
job_id: None,
});
+1
View File
@@ -1321,6 +1321,7 @@ mod tests {
token_prefix: None,
read_only: false,
job_id,
credential_expiry: None,
}
}
+272
View File
@@ -0,0 +1,272 @@
//! The guest JWT contract: what a token minted by an embedding customer's own backend
//! must carry to open one guest-mode app, and how it is verified against the key the
//! workspace admin configured. Deliberately narrower than the external JWT scheme
//! (`jwt_ext_`), whose claims can assert admin, groups and folders: a guest key can
//! only ever mint guests, whatever the token says.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use jsonwebtoken::{
jwk::{AlgorithmParameters, Jwk, JwkSet, PublicKeyUse},
Algorithm, DecodingKey, Validation,
};
use quick_cache::sync::Cache;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::DB;
/// A token is honoured at most this long past its issue, however far its `exp` lies:
/// a guest's expiry is its only revocation, and a long-lived token minted by mistake
/// would otherwise stay valid until it leaked.
pub const MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
/// Bearer prefix. Stateless: verified per request and cached until `exp`, no row.
pub const BEARER_PREFIX: &str = "jwt_guest_";
/// Segment that marks a guest JWT on an app share link, right before the token:
/// `/public/<workspace>/<secret>/guest/<jwt>`. Distinct from the external JWT's bare
/// trailing segment so the page can tell the two apart without parsing either.
pub const SHARE_LINK_SEGMENT: &str = "guest";
const RSA_ALGORITHMS: [Algorithm; 6] = [
Algorithm::RS256,
Algorithm::RS384,
Algorithm::RS512,
Algorithm::PS256,
Algorithm::PS384,
Algorithm::PS512,
];
const EC_ALGORITHMS: [Algorithm; 2] = [Algorithm::ES256, Algorithm::ES384];
/// Every claim honoured. Extra claims are ignored; a missing one refuses the token.
/// `app_path` is mandatory: a token opens that one app, exactly as a signed-in guest
/// session does.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct GuestJwtClaims {
pub email: String,
pub workspace_id: String,
pub app_path: String,
pub exp: u64,
pub nbf: Option<u64>,
pub iat: Option<u64>,
}
/// The key a workspace verifies guest JWTs with. `None` when the workspace has not
/// configured one: a guest JWT is then refused whatever it carries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestJwtKeySource {
Pem(String),
JwksUrl(String),
}
pub async fn key_source(db: &DB, w_id: &str) -> Result<Option<GuestJwtKeySource>> {
let row = sqlx::query!(
"SELECT guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.fetch_optional(db)
.await
.map_err(|e| Error::internal_err(format!("reading guest JWT key of {w_id}: {e:#}")))?;
Ok(row.and_then(|r| match (r.guest_jwt_public_key, r.guest_jwt_jwks_url) {
(Some(pem), _) => Some(GuestJwtKeySource::Pem(pem)),
(None, Some(url)) => Some(GuestJwtKeySource::JwksUrl(url)),
(None, None) => None,
}))
}
/// Parse a PEM public key and the algorithms it may verify: RSA keys the RS/PS family,
/// EC keys the ES family. Anything symmetric has no PEM form, so HS* is unreachable
/// from here by construction; the JWKS path refuses it explicitly.
pub fn decoding_key_from_pem(pem: &str) -> Result<(DecodingKey, &'static [Algorithm])> {
let pem = pem.trim();
if let Ok(key) = DecodingKey::from_rsa_pem(pem.as_bytes()) {
return Ok((key, &RSA_ALGORITHMS));
}
if let Ok(key) = DecodingKey::from_ec_pem(pem.as_bytes()) {
return Ok((key, &EC_ALGORITHMS));
}
Err(Error::BadRequest(
"not an RSA or EC public key in PEM form (expected -----BEGIN PUBLIC KEY-----)"
.to_string(),
))
}
/// The one algorithm a JWKS key verifies, or `None` if the key is unusable here: a
/// symmetric key (HS*, a shared secret the embedder would then have to hold), an
/// unsupported family, or a key not marked for signatures.
pub fn jwk_algorithm(jwk: &Jwk) -> Option<Algorithm> {
if jwk.common.public_key_use.is_some()
&& jwk.common.public_key_use != Some(PublicKeyUse::Signature)
{
return None;
}
match (&jwk.algorithm, jwk.common.algorithm) {
(AlgorithmParameters::RSA(_), Some(alg)) if RSA_ALGORITHMS.contains(&alg) => Some(alg),
(AlgorithmParameters::RSA(_), None) => Some(Algorithm::RS256),
(AlgorithmParameters::EllipticCurve(_), Some(alg)) if EC_ALGORITHMS.contains(&alg) => {
Some(alg)
}
(AlgorithmParameters::EllipticCurve(p), None) => match p.curve {
jsonwebtoken::jwk::EllipticCurve::P256 => Some(Algorithm::ES256),
jsonwebtoken::jwk::EllipticCurve::P384 => Some(Algorithm::ES384),
_ => None,
},
_ => None,
}
}
/// Verify `token` against `key`, honouring only the accepted `algorithms`, and check
/// every claim rule that needs no database: signature, `exp` (mandatory), `nbf` and
/// `iat` when present, the lifetime cap, and that the token names `w_id`.
pub fn verify(
token: &str,
key: &DecodingKey,
algorithms: &[Algorithm],
w_id: &str,
) -> Result<GuestJwtClaims> {
let mut validation = Validation::new(algorithms[0]);
validation.algorithms = algorithms.to_vec();
validation.validate_nbf = true;
let claims = jsonwebtoken::decode::<GuestJwtClaims>(token, key, &validation)
.map_err(|e| Error::NotAuthorized(format!("guest JWT refused: {e}")))?
.claims;
let now = jsonwebtoken::get_current_timestamp();
if claims.exp > now + MAX_LIFETIME_SECS {
return Err(Error::NotAuthorized(format!(
"guest JWT refused: exp is more than {MAX_LIFETIME_SECS} seconds ahead"
)));
}
if let Some(iat) = claims.iat {
if iat > now + validation.leeway {
return Err(Error::NotAuthorized(
"guest JWT refused: iat is in the future".to_string(),
));
}
if claims.exp.saturating_sub(iat) > MAX_LIFETIME_SECS {
return Err(Error::NotAuthorized(format!(
"guest JWT refused: lifetime exceeds {MAX_LIFETIME_SECS} seconds"
)));
}
}
if claims.workspace_id != w_id {
return Err(Error::NotAuthorized(
"guest JWT refused: workspace_id does not match the workspace".to_string(),
));
}
Ok(claims)
}
struct JwksEntry {
keys: Arc<HashMap<String, Jwk>>,
fetched_at: Instant,
}
lazy_static::lazy_static! {
static ref JWKS_CACHE: Cache<String, Arc<JwksEntry>> = Cache::new(200);
}
/// How long a fetched key set is served before being refreshed. The same cadence as
/// the instance-level external JWKS.
const JWKS_TTL: Duration = Duration::from_secs(15 * 60);
/// A `kid` missing from a set fetched longer ago than this refetches once: that is
/// what a key rotation looks like. Floored so unknown `kid`s cannot drive fetches.
const JWKS_MISS_REFETCH_FLOOR: Duration = Duration::from_secs(60);
/// Fetch a JWKS, keeping only the keys usable here. The URL was set by a workspace
/// admin, so it is validated against private ranges and the connect is pinned to the
/// validated addresses; redirects are not followed for the same reason.
pub async fn fetch_jwks(url: &str) -> Result<HashMap<String, Jwk>> {
let target = crate::ssrf::validate_guest_jwks_url(url)
.await
.map_err(|e| Error::BadRequest(format!("JWKS URL is not allowed: {e}")))?;
let client = target
.apply_dns_pinning(crate::utils::configure_client(reqwest::ClientBuilder::new()))
.user_agent("windmill/beta")
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| Error::internal_err(format!("building JWKS client: {e}")))?;
let set = client
.get(url)
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| Error::BadRequest(format!("could not fetch JWKS: {e}")))?
.json::<JwkSet>()
.await
.map_err(|e| Error::BadRequest(format!("JWKS is not a JSON Web Key Set: {e}")))?;
let keys: HashMap<String, Jwk> = set
.keys
.into_iter()
.filter(|jwk| jwk_algorithm(jwk).is_some())
.filter_map(|jwk| jwk.common.key_id.clone().map(|kid| (kid, jwk)))
.collect();
if keys.is_empty() {
return Err(Error::BadRequest(
"JWKS holds no RSA or EC signing key with a kid".to_string(),
));
}
Ok(keys)
}
async fn cached_jwks(url: &str) -> Result<Arc<JwksEntry>> {
if let Some(entry) = JWKS_CACHE.get(url) {
if entry.fetched_at.elapsed() < JWKS_TTL {
return Ok(entry);
}
}
refetch_jwks(url).await
}
async fn refetch_jwks(url: &str) -> Result<Arc<JwksEntry>> {
let keys = fetch_jwks(url).await?;
let entry = Arc::new(JwksEntry { keys: Arc::new(keys), fetched_at: Instant::now() });
JWKS_CACHE.insert(url.to_string(), entry.clone());
Ok(entry)
}
/// The key a token's header selects from the workspace's JWKS, by `kid`.
pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Algorithm)> {
let header = jsonwebtoken::decode_header(token)
.map_err(|e| Error::NotAuthorized(format!("guest JWT refused: {e}")))?;
let kid = header.kid.ok_or_else(|| {
Error::NotAuthorized("guest JWT refused: no kid in the header".to_string())
})?;
let mut entry = cached_jwks(url).await?;
if !entry.keys.contains_key(&kid) && entry.fetched_at.elapsed() >= JWKS_MISS_REFETCH_FLOOR {
entry = refetch_jwks(url).await?;
}
let jwk = entry.keys.get(&kid).ok_or_else(|| {
Error::NotAuthorized(format!("guest JWT refused: kid {kid} is not in the JWKS"))
})?;
let alg = jwk_algorithm(jwk).ok_or_else(|| {
Error::NotAuthorized(format!("guest JWT refused: kid {kid} is not a signing key"))
})?;
let key = DecodingKey::from_jwk(jwk)
.map_err(|e| Error::internal_err(format!("unusable JWK {kid}: {e}")))?;
Ok((key, alg))
}
/// Verify `token` for `w_id` against whatever key the workspace configured. A PEM key
/// ignores `kid`; a JWKS selects by it.
pub async fn verify_for_workspace(db: &DB, w_id: &str, token: &str) -> Result<GuestJwtClaims> {
let Some(source) = key_source(db, w_id).await? else {
return Err(Error::NotAuthorized(format!(
"guest JWT refused: workspace {w_id} has no guest JWT key"
)));
};
match source {
GuestJwtKeySource::Pem(pem) => {
let (key, algorithms) = decoding_key_from_pem(&pem)?;
verify(token, &key, algorithms, w_id)
}
GuestJwtKeySource::JwksUrl(url) => {
let (key, alg) = jwks_key_for(&url, token).await?;
verify(token, &key, &[alg], w_id)
}
}
}
+1
View File
@@ -67,6 +67,7 @@ pub mod flow_status;
pub mod flows;
pub mod folders;
pub mod global_settings;
pub mod guest_jwt;
pub mod indexer;
pub mod instance_config;
pub mod job_metrics;
+26
View File
@@ -6,6 +6,8 @@ pub const ALLOW_PRIVATE_MCP_SERVER_URLS_ENV: &str = "ALLOW_PRIVATE_MCP_SERVER_UR
pub const ALLOW_PRIVATE_SAML_METADATA_URLS_ENV: &str = "ALLOW_PRIVATE_SAML_METADATA_URLS";
pub const ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV: &str = "ALLOW_PRIVATE_GUEST_JWKS_URLS";
/// Why a URL failed SSRF validation.
///
/// The distinction matters for callers that gate private endpoints behind a
@@ -213,6 +215,30 @@ pub async fn validate_saml_metadata_url(url: &str) -> Result<ValidatedTarget, Ss
validate_url_for_ssrf(url).await
}
/// Validate a workspace admin's guest-JWKS URL and return the [`ValidatedTarget`] so
/// the fetch can pin the connect. Same shape as [`validate_mcp_server_url`]: a private
/// range is refused unless the operator opts in with `ALLOW_PRIVATE_GUEST_JWKS_URLS`.
pub async fn validate_guest_jwks_url(url: &str) -> Result<ValidatedTarget, SsrfValidationError> {
let parsed =
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
match parsed.scheme() {
"http" | "https" => {}
scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())),
}
let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?;
if std::env::var(ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV)
.ok()
.is_some_and(|v| v == "true" || v == "1")
{
return Ok(ValidatedTarget::unpinned(host));
}
validate_url_for_ssrf(url).await
}
pub async fn validate_mcp_server_url(url: &str) -> Result<ValidatedTarget, SsrfValidationError> {
let parsed =
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
+19
View File
@@ -36,6 +36,25 @@ pub const PERMISSIONED_AS_MAX_LEN: usize = 55;
/// decided before the group convention — an address is never a group's username — and one
/// containing `/` is prefixed, since readers split on the first `/` and would otherwise take
/// `g/alice@example.com` for a group.
/// Whether any account exists for `email`: a `password` row (deactivated ones
/// included, since the sign-in path filters `disabled = false` and a re-enabled
/// account must not read as absent) or a `usr` row in any workspace (what a service
/// account has instead of a password). A guest is someone with none: the single rule
/// that keeps an account holder from ever holding a cheaper guest identity.
pub async fn has_any_account<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
executor: E,
email: &str,
) -> crate::error::Result<bool> {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
)
.bind(email)
.fetch_one(executor)
.await
.map_err(|e| crate::error::Error::internal_err(format!("checking account for {email}: {e:#}")))
}
pub fn username_to_permissioned_as(user: &str) -> String {
if user.contains('@') {
return if user.contains('/') {
@@ -190,6 +190,19 @@
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
let guestAccessEnabled: boolean = $state(false)
let initialGuestAccessEnabled: boolean = $state(false)
// A guest JWT is verified against one key: a PEM public key, or a JWKS URL. The
// type picks which field is live; the other is cleared on save.
let guestJwtKeyType = $state<'pem' | 'jwks'>('pem')
let guestJwtPublicKey: string = $state('')
let guestJwtJwksUrl: string = $state('')
let initialGuestJwtPublicKey: string = $state('')
let initialGuestJwtJwksUrl: string = $state('')
// The pair actually saved: only the selected type's field, trimmed. The unselected
// one is empty, so switching type and saving clears what was there.
let effectiveGuestJwt = $derived({
pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '',
jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : ''
})
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
let hasInstanceAiConfig = $state(false)
@@ -534,6 +547,25 @@
if (guestAccessEnabled !== initialGuestAccessEnabled) {
await editGuestAccess()
}
if (
effectiveGuestJwt.pem !== initialGuestJwtPublicKey ||
effectiveGuestJwt.jwks !== initialGuestJwtJwksUrl
) {
await editGuestJwtKey()
}
}
async function editGuestJwtKey(): Promise<void> {
await WorkspaceService.editGuestJwtKey({
workspace: $workspaceStore!,
requestBody: {
public_key: effectiveGuestJwt.pem || undefined,
jwks_url: effectiveGuestJwt.jwks || undefined
}
})
initialGuestJwtPublicKey = effectiveGuestJwt.pem
initialGuestJwtJwksUrl = effectiveGuestJwt.jwks
sendUserToast('Guest JWT key updated')
}
async function editGuestAccess(): Promise<void> {
@@ -644,6 +676,11 @@
initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
guestAccessEnabled = settings.guest_access_enabled ?? false
initialGuestAccessEnabled = settings.guest_access_enabled ?? false
guestJwtPublicKey = settings.guest_jwt_public_key ?? ''
guestJwtJwksUrl = settings.guest_jwt_jwks_url ?? ''
initialGuestJwtPublicKey = guestJwtPublicKey
initialGuestJwtJwksUrl = guestJwtJwksUrl
guestJwtKeyType = guestJwtJwksUrl ? 'jwks' : 'pem'
if (emptyString($enterpriseLicense)) {
errorHandlerSelected = 'custom'
} else if (
@@ -1046,12 +1083,16 @@
savedValue: {
defaultAppPath: initialWorkspaceDefaultAppPath,
publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute,
guestAccessEnabled: initialGuestAccessEnabled
guestAccessEnabled: initialGuestAccessEnabled,
guestJwtPem: initialGuestJwtPublicKey,
guestJwtJwks: initialGuestJwtJwksUrl
},
modifiedValue: {
defaultAppPath: workspaceDefaultAppPath,
publicAppRateLimitPerMinute: publicAppRateLimitPerMinute,
guestAccessEnabled: guestAccessEnabled
guestAccessEnabled: guestAccessEnabled,
guestJwtPem: effectiveGuestJwt.pem,
guestJwtJwks: effectiveGuestJwt.jwks
}
}
}
@@ -1061,6 +1102,9 @@
workspaceDefaultAppPath = initialWorkspaceDefaultAppPath
publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute
guestAccessEnabled = initialGuestAccessEnabled
guestJwtPublicKey = initialGuestJwtPublicKey
guestJwtJwksUrl = initialGuestJwtJwksUrl
guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem'
}
// Strip keys from extraArgs that are auto-managed by child components:
@@ -2191,6 +2235,40 @@ export async function main(
Guest sign-in requires a Windmill Enterprise plan.
</span>
{/if}
{#if isEnterprisePlan($enterpriseLicense)}
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="text-xs font-semibold text-emphasis">
Guest JWT verification key
</div>
<div class="text-2xs text-hint">
A guest can also enter through a JWT your own backend mints and signs, with
no identity-provider round-trip, for iframe embedding. The token must carry
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
<code>app_path</code>. Accepted algorithms: RS256/384/512, PS256/384/512,
ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM
public key or a JWKS URL.
</div>
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
{#snippet children({ item })}
<ToggleButton {item} value="pem" label="PEM public key" />
<ToggleButton {item} value="jwks" label="JWKS URL" />
{/snippet}
</ToggleButtonGroup>
{#if guestJwtKeyType === 'pem'}
<textarea
class="w-full h-32 font-mono text-xs p-2 border rounded resize-y bg-surface text-primary"
placeholder={'-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'}
bind:value={guestJwtPublicKey}
></textarea>
{:else}
<TextInput
inputProps={{ placeholder: 'https://issuer.example.com/.well-known/jwks.json' }}
bind:value={guestJwtJwksUrl}
/>
{/if}
</div>
{/if}
</SettingCard>
<SettingsFooter
+19 -12
View File
@@ -30,19 +30,24 @@
}
}
function parseCustomPath(customPath: string): { path: string; jwt: string | undefined } {
// The custom path may carry a trailing credential: an external JWT as its last
// segment, or a guest JWT preceded by a `guest` marker (`<path>/guest/<jwt>`). The
// marker keeps the two apart; `viewerUrl` uses `path` alone, so neither reaches the
// opaque iframe.
function parseCustomPath(customPath: string): {
path: string
jwt: string | undefined
guestJwt: string | undefined
} {
const parts = customPath.split('/')
if (parts.length > 1 && isJwt(parts[parts.length - 1])) {
return {
path: parts.slice(0, -1).join('/'),
jwt: parts[parts.length - 1]
}
} else {
return {
path: customPath,
jwt: undefined
}
const n = parts.length
if (n > 2 && parts[n - 2] === 'guest' && isJwt(parts[n - 1])) {
return { path: parts.slice(0, -2).join('/'), jwt: undefined, guestJwt: parts[n - 1] }
}
if (n > 1 && isJwt(parts[n - 1])) {
return { path: parts.slice(0, -1).join('/'), jwt: parts[n - 1], guestJwt: undefined }
}
return { path: customPath, jwt: undefined, guestJwt: undefined }
}
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
@@ -102,7 +107,9 @@
// Embedder side: validate access (main session cookie or shared JWT) and mint
// a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
if (parsedCustomPath.jwt) {
if (parsedCustomPath.guestJwt) {
OpenAPI.TOKEN = 'jwt_guest_' + parsedCustomPath.guestJwt
} else if (parsedCustomPath.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
}
const headers: Record<string, string> = {}
@@ -27,12 +27,21 @@
* offering an ordinary sign-in on a transient fault would provision an account. */
let guestEntry: 'pending' | 'none' | 'guest' | 'error' = $state('pending')
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
// The share link carries a trailing credential the embedder consumes: an external
// JWT as `<secret>/<jwt>`, or a guest JWT as `<secret>/guest/<jwt>`. The `guest`
// marker keeps the two apart with no parsing of the token, which the page cannot
// verify anyway. Either way `viewerUrl` below uses `secret` alone, so no JWT
// reaches the opaque iframe.
function parseSecret(secret: string): {
secret: string
jwt: string | undefined
guestJwt: string | undefined
} {
const parts = secret.split('/')
return {
secret: parts[0],
jwt: parts[1]
if (parts[1] === 'guest' && parts[2]) {
return { secret: parts[0], jwt: undefined, guestJwt: parts[2] }
}
return { secret: parts[0], jwt: parts[1], guestJwt: undefined }
}
const parsedSecret = parseSecret(page.params.secret ?? '')
@@ -52,7 +61,9 @@
// Embedder side: validate access (using the main session cookie or the shared
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
if (parsedSecret.jwt) {
if (parsedSecret.guestJwt) {
OpenAPI.TOKEN = 'jwt_guest_' + parsedSecret.guestJwt
} else if (parsedSecret.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
}
const headers: Record<string, string> = {}