mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
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:
co-authored by
Claude Opus 4.8
parent
7dd5ccd374
commit
9cb8e991eb
+10
-4
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
Generated
+1
@@ -14763,6 +14763,7 @@ dependencies = [
|
||||
"git-version",
|
||||
"hex",
|
||||
"hmac",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"once_cell",
|
||||
"opentelemetry 0.30.0",
|
||||
|
||||
@@ -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 @@
|
||||
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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -11618,6 +11618,7 @@ mod approval_view_gate_tests {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,7 @@ async fn inject_agent_authed(
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
},
|
||||
job_id: None,
|
||||
});
|
||||
|
||||
@@ -1321,6 +1321,7 @@ mod tests {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
Reference in New Issue
Block a user