mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
feat: guest JWT entry for embedded apps (#10954)
* feat: guest JWT entry for embedded apps (jwt_guest_) A second way in for a guest, alongside the signed-in guest session: a JWT the embedding customer's backend mints and signs, verified per request against a per-workspace key (a PEM public key or a JWKS URL), resolving to the same seatless guest identity confined to the one app its app_path claim names. Bearer prefix jwt_guest_, stateless (no token row). See PR #10954. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: surface guest JWT as the embed method in the app deploy drawer The deploy drawer explained the secret-URL embed but not the guest JWT path, so the primary way to embed an app for a customer's own authenticated users was undiscoverable. For a guest-mode app with guests enabled, show how to mint a `jwt_guest_` token and append `guest.<jwt>` to the app URL, with a copyable iframe template pre-filled with this app's workspace_id and app_path, and a note that new guest emails are refused past the instance's free allowance (the live count is shown just above). Also log a guest JWT allowance refusal at warn, not info: the caller gets a bare 401 (the reason must not leak to an unauthenticated caller), so the log is the admin's signal that the instance hit its guest cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: correct the guest JWT minting instructions in the embed block The block said "sign it with the workspace's guest JWT key", but that setting holds the public verification key. Clarify the keypair relationship (configure the public key or a JWKS URL in the workspace; sign with the matching private key), name the accepted algorithms (RS/PS/ES; HS* refused), and keep the required claims, so an embedder knows how to actually mint the token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: fall back to the instance JWT issuer for guest verification (off on cloud) A workspace with no guest key of its own now verifies guest JWTs against the instance issuer (JWT_EXT_JWKS_URL, already used by jwt_ext_), so an operator running one issuer configures it once. Verification and the guest grant are CE; granting a full login from that issuer stays EE (jwt_ext_, unchanged). Disabled under CLOUD_HOSTED, where one instance issuer must not be trusted to mint guests in every tenant's workspace — there the per-workspace key is the only source, which also stays the override everywhere. The workspace settings note (hidden on cloud) explains the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: embed instructions cover both the workspace key and instance issuer The embed block said to set the workspace's guest JWT key; now it says Windmill verifies against the workspace key or, off cloud, the instance issuer (JWT_EXT_JWKS_URL) when no workspace key is set. The instance clause is hidden under isCloudHosted(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: show the guest JWT embed block only when Embed is toggled It belongs with the iframe snippet, not the plain-URL view, so gate it on embedMode alongside the guest-mode / guests-enabled checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: trust the instance issuer in the guest fallback; refresh stale docs P1 (CI review): the fallback wrapped JWT_EXT_JWKS_URL as a workspace JwksUrl, so it hit validate_guest_jwks_url and was refused for http/private issuers unless ALLOW_PRIVATE_GUEST_JWKS_URLS was also set — a self-hosted internal issuer that works for jwt_ext_ failed for guests, though the UI says setting the env var is enough. fetch_jwks now fetches the instance issuer without the https/private restriction (matching the jwt_ext_ loader; it stays operator-trusted), while a workspace-admin URL is validated and pinned as before. All the size/key/URL bounds still apply to both. P2 (CI review): refresh the stale docs that said a missing workspace key always refuses a guest JWT — the module, bearer, key-source, and EditGuestJwtKey field docs now describe the workspace key with the off-cloud instance-issuer fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: fetch the trusted instance issuer like the jwt_ext_ loader P1 (CI review): the instance-issuer fetch skipped SSRF validation but still disabled redirects and default cert validation, so an instance issuer that works for jwt_ext_ through a redirect or an operator-approved self-signed cert failed the guest fallback. Fetch it with HTTP_CLIENT_PERMISSIVE (follows redirects, honors ACCEPT_INVALID_CERTS) — the same behavior jwt_ext_ has — while a workspace-admin URL stays validated, DNS-pinned and redirect-free. The body size cap still bounds both. P2 (CI review): the WorkspaceSettings field doc still said None/None means no JWT guests; it now names the off-cloud instance-issuer fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: schema summary + OpenAPI cover the guest JWT columns and fallback P2 (CI review): summarized_schema.txt was missing guest_activity.jwt_entry and the two workspace_settings guest-JWT key columns (required by docs/validation.md after a schema change). The edit_guest_jwt_key OpenAPI description now notes that clearing the workspace key falls back to the instance issuer (JWT_EXT_JWKS_URL) off cloud rather than necessarily stopping guest JWTs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: keep JWKS single-flight locks in a self-cleaning map, not a bounded cache P1 (CI review): JWKS_FETCH_LOCKS was a 200-entry quick_cache. Past 200 cold URLs it can evict a lock whose fetch is still in flight; the next request for that URL then mints a fresh lock and starts a second fetch, so cycling configured workspaces defeats single-flight and can storm the issuers. Replace it with a plain map guarded by a JwksFetchLock RAII handle that removes each entry once its last holder drops, so the map only ever holds the fetches in flight and never evicts an in-flight lock. Add a unit test pinning the shared-lock and self-cleaning invariants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: update ee-repo-ref to c2270eb5fe2d9f0968253e6b460c33186363f4e7 This commit updates the EE repository reference after PR #773 was merged in windmill-ee-private. Previous ee-repo-ref: 5a1d9dee34159512c0823fddcd3d096490edbcce New ee-repo-ref: c2270eb5fe2d9f0968253e6b460c33186363f4e7 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
windmill-internal-app[bot]
parent
f977f5bf8b
commit
8aab5034a6
+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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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()\n WHERE NOT guest_activity.jwt_entry\n RETURNING 1 AS \"audited!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "audited!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0fc900f73ef119e4c89186cf120938a298bfe29afe1fd7111340252877f8b86c"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e2eee8de61337b7d093f38e3e393e3620111119abf8dda4bde5b835ff934e4f1"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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
+3
@@ -14764,6 +14764,7 @@ dependencies = [
|
||||
"git-version",
|
||||
"hex",
|
||||
"hmac",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"once_cell",
|
||||
"opentelemetry 0.30.0",
|
||||
@@ -15601,6 +15602,7 @@ dependencies = [
|
||||
"pep440_rs",
|
||||
"phf 0.11.3",
|
||||
"pin-project-lite",
|
||||
"pkcs1",
|
||||
"postgres-native-tls 0.5.3",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
@@ -15617,6 +15619,7 @@ dependencies = [
|
||||
"serde_yml",
|
||||
"sha2 0.10.9",
|
||||
"size",
|
||||
"spki",
|
||||
"sqlx",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
|
||||
@@ -367,6 +367,7 @@ aws-config.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -599,6 +600,8 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
|
||||
const-str = "0.5"
|
||||
constant_time_eq = "0.3.1"
|
||||
rsa = "^0"
|
||||
spki = { version = "0.7", features = ["pem"] }
|
||||
pkcs1 = "0.7"
|
||||
aes-gcm = "0.10.3"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
|
||||
once_cell = "1.17.1"
|
||||
|
||||
@@ -1 +1 @@
|
||||
fb1c5c109846d6c47aff70ab6cc631f4fd773678
|
||||
c2270eb5fe2d9f0968253e6b460c33186363f4e7
|
||||
|
||||
@@ -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;
|
||||
@@ -110,7 +110,7 @@ folder_permission_history: id(bigint), workspace_id(char), folder_name(char), ch
|
||||
FK: (workspace_id, folder_name) -> folder(workspace_id, name)
|
||||
gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[])
|
||||
global_settings: name(char), value(jsonb), updated_at(ts)
|
||||
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz)
|
||||
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz), jwt_entry(bool)
|
||||
group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char)
|
||||
@@ -223,7 +223,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -614,8 +614,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renaming a workspace copies its settings; the guest switch must travel with them,
|
||||
/// or the rename silently shuts every guest app of the workspace.
|
||||
/// Renaming a workspace copies its settings; the guest switch and the guest JWT key must
|
||||
/// travel with them, or the rename silently shuts every guest app or drops the key.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -629,6 +629,11 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET guest_jwt_public_key = 'test-pem-key' WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
|
||||
@@ -645,6 +650,16 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(enabled, "the guest switch travels with the workspace");
|
||||
let jwt_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
jwt_key.as_deref(),
|
||||
Some("test-pem-key"),
|
||||
"the guest JWT key travels with the workspace"
|
||||
);
|
||||
let moved: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace-2')
|
||||
AND NOT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace')",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The guest allowance reached through a guest JWT (`jwt_guest_`). Its own binary
|
||||
//! because `set_plan` flips a process-global license key, which a test sharing the
|
||||
//! process could not tolerate (see `app_guest_allowance.rs`).
|
||||
//!
|
||||
//! Users from the `base` fixture:
|
||||
//! test-user (admin, token SECRET_TOKEN)
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::FREE_GUESTS_PER_WINDOW;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
const APP_PATH: &str = "u/test-user/guest_app";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
/// Community and Pro are capped, Enterprise is metered. Only a build with both
|
||||
/// `private` and `enterprise` can meter; every other build is capped whatever this says.
|
||||
fn set_plan(pro: bool) {
|
||||
#[cfg(feature = "private")]
|
||||
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(
|
||||
if pro { "test_pro" } else { "" }.to_string(),
|
||||
));
|
||||
let _ = pro;
|
||||
}
|
||||
|
||||
const JWT_PUB: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzAfqyCh34iYOCW0vg4ejq/zzJlzL\nSZScjnVyPjLGTapEwo4gc6/y1Yudd/v54wKh0OdfTfzAKMPWx/2NWx/ugg==\n-----END PUBLIC KEY-----\n";
|
||||
const JWT_PRIV: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgu27S2DbSwUh8BmQb\n/i4/VhNdoXV7PJekhnoceMULYLihRANCAATMB+rIKHfiJg4JbS+Dh6Or/PMmXMtJ\nlJyOdXI+MsZNqkTCjiBzr/LVi513+/njAqHQ519N/MAow9bH/Y1bH+6C\n-----END PRIVATE KEY-----\n";
|
||||
|
||||
fn guest_jwt(email: &str) -> String {
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
let exp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 3600;
|
||||
let claims = json!({
|
||||
"email": email,
|
||||
"workspace_id": "test-workspace",
|
||||
"app_path": APP_PATH,
|
||||
"exp": exp,
|
||||
});
|
||||
let jwt = encode(
|
||||
&Header::new(Algorithm::ES256),
|
||||
&claims,
|
||||
&EncodingKey::from_ec_pem(JWT_PRIV.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
format!("jwt_guest_{jwt}")
|
||||
}
|
||||
|
||||
/// A JWT guest is subject to the same allowance as a signed-in one. Past the cap on a
|
||||
/// capped instance, a stranger's JWT is refused (the auth arm returns 401; the visitor
|
||||
/// message is only logged, since the arm cannot carry it), while a guest already in the
|
||||
/// window is let back in.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_guest_jwt_is_capped_like_a_signed_in_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "public_key": JWT_PUB }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "guest", "triggerables_v2": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
// The whole allowance, used today (g1..gN).
|
||||
sqlx::query(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
SELECT 'g' || i || '@example.com', 'test-workspace', CURRENT_DATE
|
||||
FROM generate_series(1, $1) AS i",
|
||||
)
|
||||
.bind(FREE_GUESTS_PER_WINDOW)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
set_plan(true);
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{ws}/users/whoami")),
|
||||
&guest_jwt("stranger@example.com"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401, "a stranger's JWT is refused past the cap");
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{ws}/users/whoami")),
|
||||
&guest_jwt("g1@example.com"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"a returning guest's JWT is admitted: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
//! 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.
|
||||
|
||||
// Built with these like the sibling guest-execution suite: the guest run executes as
|
||||
// the publisher through EE on-behalf-of code. 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<()> {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access"
|
||||
)),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": on }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().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?;
|
||||
|
||||
// Positive control: a token valid against this exact fixture is admitted. Without it a
|
||||
// broken setup would 401 every bearer below and the whole suite would pass vacuously.
|
||||
let control = whoami(port, ws, &bearer(&Claims::valid(), PRIV1, Algorithm::ES256))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(control.status(), 200, "{}", control.text().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);
|
||||
|
||||
// an existing account addressed in a different case still counts as an account:
|
||||
// the base fixture holds `test@windmill.dev`.
|
||||
let mut c = Claims::valid();
|
||||
c.email = "Test@Windmill.Dev".to_string();
|
||||
let mixed_case_account = bearer(&c, PRIV1, Algorithm::ES256);
|
||||
|
||||
// a lifetime past the 24h cap, even with a valid signature.
|
||||
let mut c = Claims::valid();
|
||||
c.exp = now() + 25 * 3600;
|
||||
let over_lifetime_cap = bearer(&c, PRIV1, Algorithm::ES256);
|
||||
|
||||
// an email with no `@` would become the guest's username and could be read as a
|
||||
// `u/<user>` or `g/<group>` principal; refused.
|
||||
let mut c = Claims::valid();
|
||||
c.email = "group-admins".to_string();
|
||||
let group_shaped_email = bearer(&c, PRIV1, Algorithm::ES256);
|
||||
|
||||
// an email longer than the `guest_activity.email` column: refused before auth, so a
|
||||
// guest is never admitted without the activity row and audit event the count needs.
|
||||
let mut c = Claims::valid();
|
||||
c.email = format!("{}@example.com", "a".repeat(250));
|
||||
let oversized_email = bearer(&c, PRIV1, Algorithm::ES256);
|
||||
|
||||
// an app_path carrying a scope metacharacter would widen the guest's scopes.
|
||||
let mut c = Claims::valid();
|
||||
c.app_path = "u/test-user/*".to_string();
|
||||
let wildcard_app_path = bearer(&c, PRIV1, Algorithm::ES256);
|
||||
|
||||
// a valid, signed token past the length cap: without the cap it would deserialize into
|
||||
// GuestJwtClaims (the extra claim ignored) and verify, so this pins the length check.
|
||||
let mut payload = serde_json::to_value(Claims::valid()).unwrap();
|
||||
payload["padding"] = serde_json::json!("a".repeat(9000));
|
||||
let big_jwt = encode(
|
||||
&Header::new(Algorithm::ES256),
|
||||
&payload,
|
||||
&EncodingKey::from_ec_pem(PRIV1.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let oversized_token = format!("jwt_guest_{big_jwt}");
|
||||
|
||||
// a repeated prefix must not strip down to a valid short token that verifies and is then
|
||||
// cached under the full bearer key (trim_start_matches would; strip_prefix must not).
|
||||
let repeated_prefix = format!(
|
||||
"jwt_guest_{}",
|
||||
bearer(&Claims::valid(), 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),
|
||||
("mixed-case account", mixed_case_account),
|
||||
("over the 24h lifetime cap", over_lifetime_cap),
|
||||
("group-shaped email", group_shaped_email),
|
||||
("oversized email", oversized_email),
|
||||
("wildcard app_path", wildcard_app_path),
|
||||
("oversized token", oversized_token),
|
||||
("repeated prefix", repeated_prefix),
|
||||
] {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// A workspace with no guest key of its own falls back to the instance issuer
|
||||
/// (`JWT_EXT_JWKS_URL`), so an operator running one issuer configures it once. Verified as a
|
||||
/// guest here in CE; a full login from that issuer stays EE (`jwt_ext_`).
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn no_workspace_key_falls_back_to_the_instance_issuer(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::guest_jwt::{key_source, GuestJwtKeySource};
|
||||
let url = "https://issuer.example.com/jwks.json";
|
||||
unsafe { std::env::set_var("JWT_EXT_JWKS_URL", url) };
|
||||
let src = key_source(&db, "test-workspace").await;
|
||||
unsafe { std::env::remove_var("JWT_EXT_JWKS_URL") };
|
||||
assert!(
|
||||
matches!(src?, Some(GuestJwtKeySource::JwksUrl(u)) if u == url),
|
||||
"no workspace key falls back to the instance issuer"
|
||||
);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,18 @@ impl AuthCache {
|
||||
if is_no_auth() {
|
||||
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
|
||||
}
|
||||
// Reject an oversized guest bearer before the cache key is built from it: the key
|
||||
// copies and hashes the whole token, so the cap should bound that work too. Log it
|
||||
// like the other guest refusals, since get_opt_job_authed turns None into a bare 401.
|
||||
if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX)
|
||||
&& token.len() > windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
|
||||
{
|
||||
tracing::error!(
|
||||
"guest JWT refused: bearer is longer than {} bytes",
|
||||
windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let key = (
|
||||
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
|
||||
token.to_string(),
|
||||
@@ -216,6 +228,111 @@ 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;
|
||||
};
|
||||
// Strip exactly one prefix: `trim_start_matches` would strip repeated prefixes,
|
||||
// so `jwt_guest_jwt_guest_<jwt>` would reduce to a valid token that verifies and
|
||||
// is then cached under the full, non-canonical bearer key.
|
||||
let jwt = token
|
||||
.strip_prefix(windmill_common::guest_jwt::BEARER_PREFIX)
|
||||
.unwrap_or(token);
|
||||
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;
|
||||
}
|
||||
};
|
||||
// The workspace switch, the instance switch and the app being in guest
|
||||
// mode, in one answer (guest_app_admits). The door re-reads the switches
|
||||
// and the no-account rule per request through the sentinel below
|
||||
// (guest_session_stands), so turning any of them 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;
|
||||
}
|
||||
}
|
||||
// Resolve on the lowercased email: accounts are stored lowercased, so a
|
||||
// mixed-case claim would otherwise slip past the no-account gate and
|
||||
// resolve an account holder to a guest, and split the activity rows the
|
||||
// seat count reads.
|
||||
let email = claims.email.to_lowercase();
|
||||
// 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, &email).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT account check failed: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// The instance allowance, checked and recorded transactionally. A stranger
|
||||
// past the cap on a capped instance is refused here; a returning guest
|
||||
// always passes. Recording an account holder is avoided by the check above.
|
||||
if !admit_and_record_guest_jwt(&self.db, w_id, &email, &claims.app_path).await {
|
||||
return None;
|
||||
}
|
||||
// guest_session_scopes already carries the sentinel, and it is the whole
|
||||
// grant; a JWT has no label, so the sentinel is what governs it. It also
|
||||
// re-checks the path holds no scope metacharacter (verify already did).
|
||||
let scopes = match crate::scopes::guest_session_scopes(&claims.app_path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT app_path cannot be scoped for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The JWT's own expiry caps a token minted from this session. The auth
|
||||
// cache entry itself is capped far shorter (GUEST_JWT_CACHE_TTL) so a
|
||||
// rotated or cleared key stops the session on re-verification, within
|
||||
// minutes, rather than only at exp (up to 24h away).
|
||||
let credential_expiry =
|
||||
chrono::Utc.timestamp_nanos(claims.exp as i64 * 1_000_000_000);
|
||||
let cache_expiry = credential_expiry.min(chrono::Utc::now() + GUEST_JWT_CACHE_TTL);
|
||||
let authed = ApiAuthed {
|
||||
username: email.clone(),
|
||||
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,
|
||||
credential_expiry: Some(credential_expiry),
|
||||
};
|
||||
AUTH_CACHE.insert(
|
||||
key,
|
||||
ExpiringAuthCache {
|
||||
authed: authed.clone(),
|
||||
expiry: cache_expiry,
|
||||
job_id: None,
|
||||
},
|
||||
);
|
||||
Some(OptJobAuthed { authed, job_id: None })
|
||||
}
|
||||
_ if token.starts_with("jwt_") => {
|
||||
let jwt_token = token.trim_start_matches("jwt_");
|
||||
|
||||
@@ -249,6 +366,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
|
||||
@@ -363,6 +481,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@@ -416,6 +535,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@@ -494,6 +614,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
None if super_admin => {
|
||||
@@ -518,6 +639,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
@@ -555,6 +677,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@@ -574,6 +697,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -612,6 +736,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 {
|
||||
@@ -622,6 +747,127 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a guest JWT resolves from the auth cache before the arm re-runs (and
|
||||
/// re-reads the key). A guest JWT is not revocable except by the workspace switch or
|
||||
/// by rotating the key, so the entry must be short enough that a rotated key bites
|
||||
/// soon, unlike a normal token whose row can be deleted. Also what makes the
|
||||
/// day-keyed activity dedupe below reachable across a midnight.
|
||||
const GUEST_JWT_CACHE_TTL: chrono::Duration = chrono::Duration::minutes(5);
|
||||
|
||||
/// A refused JWT (a stranger past the allowance) is remembered this long so a replayed
|
||||
/// bearer does not take the instance-wide allowance advisory lock on every request.
|
||||
/// Short, so a stranger admitted once the window frees is re-checked soon.
|
||||
const GUEST_JWT_REFUSED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// One `guest_activity` upsert and one `users.login_guest` audit per email,
|
||||
// workspace and day: the arm re-runs every GUEST_JWT_CACHE_TTL, and neither the
|
||||
// seat scan nor the audit trail wants a write each time. 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);
|
||||
static ref GUEST_JWT_REFUSED_CACHE: Cache<String, std::time::Instant> = Cache::new(2000);
|
||||
}
|
||||
|
||||
/// Admit a JWT guest against the instance allowance and record today's activity, in one
|
||||
/// transaction so the advisory lock in `guest_admission` spans the count check and the
|
||||
/// row that changes it. Returns false when the allowance refuses the email or on a DB
|
||||
/// error, both of which deny the guest. Cached per email, workspace and day: a bearer
|
||||
/// replayed every request runs this at most once a day, and a refused one is remembered
|
||||
/// briefly so it does not re-take the allowance lock. `email` is already lowercased.
|
||||
async fn admit_and_record_guest_jwt(db: &DB, w_id: &str, email: &str, app_path: &str) -> bool {
|
||||
let cache_key = format!("{email}|{w_id}|{}", chrono::Utc::now().date_naive());
|
||||
if GUEST_JWT_ACTIVITY_CACHE.get(&cache_key).is_some() {
|
||||
return true;
|
||||
}
|
||||
if GUEST_JWT_REFUSED_CACHE
|
||||
.get(&cache_key)
|
||||
.is_some_and(|at| at.elapsed() < GUEST_JWT_REFUSED_TTL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut tx = match db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT tx begin failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// The allowance and the row that changes it, in one transaction: guest_admission
|
||||
// takes a transaction-scoped advisory lock, so the count check and the insert cannot
|
||||
// race two strangers past the cap. Only a real allowance refusal is negative-cached;
|
||||
// a transient DB error denies this request but must not lock the email out for 30s.
|
||||
match windmill_common::workspaces::guest_admission(&mut *tx, email).await {
|
||||
Ok(()) => {}
|
||||
Err(e @ windmill_common::error::Error::PermissionDenied(_)) => {
|
||||
// The guest hits a bare 401 (the reason must not leak to an unauthenticated caller);
|
||||
// warn so an admin sees the cap in logs, since it is the actionable signal here.
|
||||
tracing::warn!("guest JWT refused (guest allowance) for {w_id}: {e:#}");
|
||||
GUEST_JWT_REFUSED_CACHE.insert(cache_key, std::time::Instant::now());
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT allowance check failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The conditional `WHERE NOT jwt_entry` flips the flag only on its false-to-true
|
||||
// transition, so the upsert returns a row exactly once per email per day: on the
|
||||
// fresh insert, or on the first JWT after an identity-provider sign-in created
|
||||
// today's row with `jwt_entry = false`. The audit is gated on that, decided
|
||||
// atomically by the conflicting tuple, so concurrent first requests (a metered
|
||||
// instance takes no advisory lock) audit at most once.
|
||||
let first_jwt = sqlx::query_scalar!(
|
||||
r#"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()
|
||||
WHERE NOT guest_activity.jwt_entry
|
||||
RETURNING 1 AS "audited!""#,
|
||||
email,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
let first_jwt = match first_jwt {
|
||||
Ok(v) => v.is_some(),
|
||||
Err(e) => {
|
||||
tracing::error!("recording guest JWT activity for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = tx.commit().await {
|
||||
tracing::error!("guest JWT tx commit failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
GUEST_JWT_ACTIVITY_CACHE.insert(cache_key, ());
|
||||
// Audit last, best-effort, on its own connection: the EE writer swallows an
|
||||
// `audit_partitioned` failure but that failing statement still aborts the
|
||||
// transaction it runs in, so auditing before the commit would let the whole
|
||||
// activity row roll back while this returned success, admitting an uncounted guest.
|
||||
if first_jwt {
|
||||
let author = windmill_common::audit::AuditAuthor {
|
||||
email: email.to_string(),
|
||||
username: email.to_string(),
|
||||
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(app_path),
|
||||
Some([("entry", "jwt")].into()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("auditing guest JWT login for {w_id}: {e:#}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
@@ -822,6 +1068,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,33 @@ 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.
|
||||
///
|
||||
/// The sentinel here only narrows. A signed-in guest is made one by the server-minted
|
||||
/// label; a JWT guest has no label, so for it the sentinel is what governs.
|
||||
pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Vec<String>> {
|
||||
// The path is spliced into a scope, whose grammar reserves `:`, `,`, `*` and a leading
|
||||
// `/`; app paths may otherwise carry spaces and `@`, so guard only those reserved chars.
|
||||
if !windmill_common::auth::is_scope_literal_path(app_path) {
|
||||
return Err(windmill_common::error::Error::BadRequest(format!(
|
||||
"app path {app_path} is empty or cannot be scoped: `:`, `,` and `*` are reserved \
|
||||
in scopes, and a leading `/` never matches a route"
|
||||
)));
|
||||
}
|
||||
Ok(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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2938,31 +2938,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. With no ACL of its own, this list is the
|
||||
/// whole of what a guest 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) -> Result<Vec<String>> {
|
||||
if !windmill_common::auth::is_scope_literal_path(app_path) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"app path {app_path} cannot be scoped: `:`, `,` and `*` are reserved in scopes, \
|
||||
and a leading `/` never matches a route"
|
||||
)));
|
||||
}
|
||||
Ok(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`
|
||||
/// and no `usr` row: that absence is what keeps a guest off every seat counter, so
|
||||
@@ -2994,20 +2969,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)?;
|
||||
|
||||
// No account at all (see `ExecutionMode::Guest`): a deactivated `password` row
|
||||
// counts, since the sign-in path's own lookup filters on `disabled = false` and a
|
||||
// SCIM-offboarded account would otherwise read as absent; so does a `usr` row in
|
||||
// any workspace, 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 {
|
||||
// No account at all (see `has_any_account`): an account holder is refused a guest
|
||||
// session, never handed a second, cheaper identity. The same helper the JWT arm uses.
|
||||
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(),
|
||||
));
|
||||
@@ -3061,7 +3027,7 @@ pub async fn create_guest_session_token<'c>(
|
||||
ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
None,
|
||||
Some([("entry", "idp")].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ use windmill_api_auth::{
|
||||
};
|
||||
use windmill_api_users::users::WorkspaceInvite;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||||
use windmill_common::webhook::WebhookShared;
|
||||
use windmill_common::{BASE_URL, DB};
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -152,6 +152,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("/guest_usage", get(get_guest_usage))
|
||||
.route("/default_app", get(get_default_app))
|
||||
.route(
|
||||
@@ -322,6 +323,14 @@ pub struct WorkspaceSettings {
|
||||
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). 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` falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch.
|
||||
#[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
|
||||
@@ -1082,7 +1091,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
|
||||
@@ -4661,6 +4672,66 @@ 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
|
||||
/// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`.
|
||||
public_key: Option<String>,
|
||||
jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
|
||||
/// Workspace-admin gated, like the guest switch: guests are free up to the instance
|
||||
/// allowance on any plan, so configuring their key needs no licence. 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)?;
|
||||
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>,
|
||||
@@ -11172,6 +11243,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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11204,6 +11276,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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
|
||||
@@ -3785,6 +3785,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:
|
||||
@@ -5808,6 +5814,43 @@ 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 workspace key; off cloud, verification then
|
||||
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
|
||||
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
|
||||
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/guest_usage:
|
||||
get:
|
||||
summary: the instance's standing against the guest allowance
|
||||
|
||||
@@ -1520,20 +1520,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(),
|
||||
|
||||
@@ -11716,6 +11716,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,
|
||||
});
|
||||
|
||||
@@ -1421,6 +1421,7 @@ mod tests {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ pep440_rs.workspace = true
|
||||
systemstat.workspace = true
|
||||
size.workspace = true
|
||||
rsa = { workspace = true, optional = true }
|
||||
spki = { workspace = true }
|
||||
pkcs1 = { workspace = true }
|
||||
aes-gcm = { workspace = true, optional = true }
|
||||
|
||||
semver.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -18,6 +20,9 @@ pub enum SsrfValidationError {
|
||||
InvalidUrl(String),
|
||||
/// Scheme is not `http`/`https`.
|
||||
DisallowedScheme(String),
|
||||
/// The URL uses `http` where `https` is required (guest JWKS). The private-host opt-in
|
||||
/// also permits `http`, so, unlike the other scheme errors, this one the flag can fix.
|
||||
HttpsRequired,
|
||||
/// No host in the URL.
|
||||
MissingHost,
|
||||
/// DNS resolution failed for the host.
|
||||
@@ -37,6 +42,9 @@ impl std::fmt::Display for SsrfValidationError {
|
||||
f,
|
||||
"URL scheme '{s}' is not allowed, only http and https are permitted"
|
||||
),
|
||||
SsrfValidationError::HttpsRequired => {
|
||||
write!(f, "URL must use https")
|
||||
}
|
||||
SsrfValidationError::MissingHost => write!(f, "URL must have a host"),
|
||||
SsrfValidationError::ResolutionFailed { host, source } => {
|
||||
write!(f, "Failed to resolve host '{host}': {source}")
|
||||
@@ -213,6 +221,36 @@ 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. `https` is required (the JWKS authenticates guest JWTs);
|
||||
/// `ALLOW_PRIVATE_GUEST_JWKS_URLS` opts a private range AND plaintext `http` in, for dev.
|
||||
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()))?;
|
||||
|
||||
let allow_private = std::env::var(ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV)
|
||||
.ok()
|
||||
.is_some_and(|v| v == "true" || v == "1");
|
||||
|
||||
match parsed.scheme() {
|
||||
"https" => {}
|
||||
// Plaintext HTTP only under the explicit operator opt-in that also allows private
|
||||
// hosts (dev/loopback): the JWKS supplies the keys that authenticate guest JWTs, so an
|
||||
// on-path attacker who could replace an http response could forge accepted tokens.
|
||||
"http" if allow_private => {}
|
||||
"http" => return Err(SsrfValidationError::HttpsRequired),
|
||||
scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())),
|
||||
}
|
||||
|
||||
let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?;
|
||||
|
||||
if allow_private {
|
||||
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()))?;
|
||||
|
||||
@@ -31,6 +31,29 @@ pub const USERNAME_GROUP_PREFIX: &str = "group-";
|
||||
/// columns runnables and triggers store one in.
|
||||
pub const PERMISSIONED_AS_MAX_LEN: usize = 55;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// The address is lowercased before the lookup: accounts are stored lowercased, so a
|
||||
/// mixed-case address would otherwise miss an existing account and be let through. The
|
||||
/// comparison stays a plain equality (not `lower(email)`), so it uses the email index.
|
||||
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.to_lowercase())
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(|e| crate::error::Error::internal_err(format!("checking account for {email}: {e:#}")))
|
||||
}
|
||||
|
||||
/// An email-shaped username is its own principal, which is how a superadmin acting without a
|
||||
/// `usr` row is named (`usr.username` is constrained to `[\w-]+`, so a member never is). It is
|
||||
/// decided before the group convention — an address is never a group's username — and one
|
||||
|
||||
@@ -176,6 +176,10 @@
|
||||
}${customPath}`
|
||||
)
|
||||
|
||||
// The app URL a guest JWT rides on: append `guest.<jwt>` and the viewer authenticates the
|
||||
// token as a seatless guest. Uses the custom URL when set, else the public secret URL.
|
||||
let guestJwtBase = $derived(customPath !== undefined ? fullCustomUrl : secretUrlHref)
|
||||
|
||||
// When embedding a raw app in an iframe inside another Windmill app (or any
|
||||
// cross-origin-isolated page), the embedded document must set COEP. The
|
||||
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
|
||||
@@ -500,8 +504,8 @@
|
||||
Anyone your identity provider authenticates can open this app without a Windmill account.
|
||||
They join no workspace. Members of this workspace can open it too.
|
||||
{#if guestUsage}
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
|
||||
instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance
|
||||
in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
|
||||
? 'every four guests count as one seat'
|
||||
: 'new guests are refused until the count drops'}.
|
||||
{/if}
|
||||
@@ -543,6 +547,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
|
||||
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Embed for your own authenticated users (guest JWT)
|
||||
</div>
|
||||
<div class="text-xs text-secondary">
|
||||
To open this app for a user your own product already authenticates, mint a short-lived JWT
|
||||
in your backend and append it to the app URL as <code>guest.<jwt></code>. Each token
|
||||
is its own seatless guest, confined to this app — no shared secret and no Windmill
|
||||
account, unlike the plain secret URL above.
|
||||
</div>
|
||||
<div class="text-xs text-secondary">
|
||||
Windmill verifies the token against the workspace's guest JWT key (Workspace settings →
|
||||
Guests) — a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's
|
||||
configured issuer (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}. Set
|
||||
the <b>public</b> half there; in your backend, sign each token with the matching
|
||||
<b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is
|
||||
refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>,
|
||||
<code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead).
|
||||
</div>
|
||||
<ClipboardPanel
|
||||
content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)}
|
||||
size="md"
|
||||
/>
|
||||
<div class="text-2xs text-secondary">
|
||||
Replace <code>YOUR_GUEST_JWT</code> with the token your backend signs per user. Past the instance's
|
||||
free guest allowance a new guest email is refused (see the count above); guests already seen
|
||||
in the window keep working.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
<Alert type="warning" title="Admin only" size="xs">
|
||||
|
||||
@@ -191,6 +191,19 @@
|
||||
let guestAccessEnabled: boolean = $state(false)
|
||||
let guestUsage: GuestUsage | undefined = $state(undefined)
|
||||
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)
|
||||
@@ -526,11 +539,17 @@
|
||||
}
|
||||
|
||||
async function saveDefaultAppSettings(): Promise<void> {
|
||||
// Guests first: the only write of this card available on every plan, so a refused
|
||||
// Enterprise-only write after it cannot swallow it.
|
||||
// Guest access and the guest JWT key are the writes of this card available on every plan;
|
||||
// save them first so a refused Enterprise-only write after cannot swallow them.
|
||||
if (guestAccessEnabled !== initialGuestAccessEnabled) {
|
||||
await editGuestAccess()
|
||||
}
|
||||
if (
|
||||
effectiveGuestJwt.pem !== initialGuestJwtPublicKey ||
|
||||
effectiveGuestJwt.jwks !== initialGuestJwtJwksUrl
|
||||
) {
|
||||
await editGuestJwtKey()
|
||||
}
|
||||
if (workspaceDefaultAppPath !== initialWorkspaceDefaultAppPath) {
|
||||
await editWorkspaceDefaultApp()
|
||||
}
|
||||
@@ -539,6 +558,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
await WorkspaceService.editGuestAccess({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -647,6 +679,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'
|
||||
WorkspaceService.getGuestUsage({ workspace: $workspaceStore! })
|
||||
.then((u) => (guestUsage = u))
|
||||
.catch(() => (guestUsage = undefined))
|
||||
@@ -1052,12 +1089,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1067,6 +1108,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:
|
||||
@@ -2184,7 +2228,7 @@ export async function main(
|
||||
|
||||
<SettingCard
|
||||
label="Guests"
|
||||
description="Let anyone your identity provider authenticates open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
|
||||
description="Let anyone your identity provider authenticates, or a JWT your own backend signs (configured below), open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
|
||||
class="mt-6"
|
||||
>
|
||||
<Toggle
|
||||
@@ -2198,8 +2242,8 @@ export async function main(
|
||||
</span>
|
||||
{:else if guestUsage}
|
||||
<span class="text-hint text-2xs">
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across
|
||||
this instance in the last {guestUsage.window_days} days.
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
|
||||
instance in the last {guestUsage.window_days} days.
|
||||
{#if guestUsage.metered}
|
||||
Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0
|
||||
? ` (${guestUsage.guest_seats} now)`
|
||||
@@ -2210,6 +2254,54 @@ export async function main(
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<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 (which must be https). Point it at an issuer you
|
||||
control: any token that key signs carrying these claims is accepted, so a shared
|
||||
multi-tenant issuer is not a good fit.
|
||||
</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'}
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
class="font-mono text-xs"
|
||||
autosizeParams={{ minHeight: 128 }}
|
||||
inputProps={{
|
||||
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
|
||||
}}
|
||||
bind:value={guestJwtPublicKey}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
|
||||
}}
|
||||
bind:value={guestJwtJwksUrl}
|
||||
/>
|
||||
{/if}
|
||||
{#if !isCloudHosted()}
|
||||
<div class="text-2xs text-hint">
|
||||
Leave empty to fall back to the instance's configured JWT issuer (<code
|
||||
>JWT_EXT_JWKS_URL</code
|
||||
>), if one is set. Set a key here to trust a different issuer for this
|
||||
workspace.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingsFooter
|
||||
@@ -2218,7 +2310,10 @@ export async function main(
|
||||
onSave={saveDefaultAppSettings}
|
||||
onDiscard={discardDefaultAppSettingsChanges}
|
||||
saveLabel="Save app settings"
|
||||
disabled={!$enterpriseLicense && guestAccessEnabled === initialGuestAccessEnabled}
|
||||
disabled={!$enterpriseLicense &&
|
||||
guestAccessEnabled === initialGuestAccessEnabled &&
|
||||
effectiveGuestJwt.pem === initialGuestJwtPublicKey &&
|
||||
effectiveGuestJwt.jwks === initialGuestJwtJwksUrl}
|
||||
/>
|
||||
{:else if tab == 'native_triggers'}
|
||||
{#if $workspaceStore}
|
||||
|
||||
@@ -19,30 +19,46 @@
|
||||
let jwtError = $state(false)
|
||||
|
||||
function isJwt(t: string) {
|
||||
// simply check that the first part is a valid base64 encoded json
|
||||
// A JWT is three dot-separated base64url segments; check the header decodes to
|
||||
// JSON. `atob` wants standard base64, so normalise base64url first (a `kid` or a
|
||||
// signature routinely contains `-`/`_`), or a valid token is taken for a path.
|
||||
try {
|
||||
const parts = t.split('.')
|
||||
const header = atob(parts[0])
|
||||
JSON.parse(header)
|
||||
if (parts.length !== 3) return false
|
||||
const b64 = parts[0].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
|
||||
JSON.parse(atob(b64 + pad))
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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 in a `guest.<jwt>` last segment (`<path>/guest.<jwt>`). The
|
||||
// `guest.` prefix 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])) {
|
||||
const last = parts[parts.length - 1]
|
||||
// A guest JWT rides the last segment prefixed `guest.`. The `.` means it can never
|
||||
// be a valid custom-path segment, so a real path ending in a `guest` segment
|
||||
// followed by an external JWT (`.../guest/<jwt>`) is read as before, not hijacked.
|
||||
if (last.startsWith('guest.') && isJwt(last.slice('guest.'.length))) {
|
||||
return {
|
||||
path: parts.slice(0, -1).join('/'),
|
||||
jwt: parts[parts.length - 1]
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
path: customPath,
|
||||
jwt: undefined
|
||||
jwt: undefined,
|
||||
guestJwt: last.slice('guest.'.length)
|
||||
}
|
||||
}
|
||||
if (parts.length > 1 && isJwt(last)) {
|
||||
return { path: parts.slice(0, -1).join('/'), jwt: last, guestJwt: undefined }
|
||||
}
|
||||
return { path: customPath, jwt: undefined, guestJwt: undefined }
|
||||
}
|
||||
|
||||
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
|
||||
@@ -102,7 +118,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,25 @@
|
||||
* 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.`
|
||||
// prefix 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]
|
||||
// The credential rides the segment after the secret: a guest JWT prefixed
|
||||
// `guest.`, or an external JWT bare. The `guest.` prefix glues the marker to the
|
||||
// token, so it can never be mistaken for a path or secret segment (which carry no
|
||||
// `.`), and a bare token keeps the established external-JWT interpretation.
|
||||
if (parts[1]?.startsWith('guest.')) {
|
||||
return { secret: parts[0], jwt: undefined, guestJwt: parts[1].slice('guest.'.length) }
|
||||
}
|
||||
return { secret: parts[0], jwt: parts[1], guestJwt: undefined }
|
||||
}
|
||||
|
||||
const parsedSecret = parseSecret(page.params.secret ?? '')
|
||||
@@ -52,7 +65,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