diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5ee1b04922..4024a9bd7b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -775,7 +775,7 @@ dependencies = [ "hmac", "http", "once_cell", - "p256", + "p256 0.11.1", "percent-encoding", "ring 0.17.7", "sha2 0.10.8", @@ -1027,6 +1027,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -1898,8 +1904,10 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ + "generic-array", "rand_core 0.6.4", "subtle", + "zeroize", ] [[package]] @@ -1912,6 +1920,34 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89b8c6a2e4b1f45971ad09761aafb85514a84744b67a95e32c3cc1352d1f65c" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "platforms", + "rustc_version 0.4.0", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "darling" version = "0.14.4" @@ -2439,11 +2475,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" dependencies = [ "der 0.6.1", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.12.3", + "rfc6979 0.3.1", "signature 1.6.4", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.8", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f628eaec48bfd21b865dc2950cfa014450c01d2fa2b69a86c2fd5844ec523c0" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.8", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.9.0" @@ -2459,16 +2533,37 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" dependencies = [ - "base16ct", + "base16ct 0.1.1", "crypto-bigint 0.4.9", "der 0.6.1", "digest 0.10.7", - "ff", + "ff 0.12.1", "generic-array", - "group", + "group 0.12.1", "pkcs8 0.9.0", "rand_core 0.6.4", - "sec1", + "sec1 0.3.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.0", + "generic-array", + "group 0.13.0", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -2708,6 +2803,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27573eac26f4dd11e2b1916c3fe1baa56407c83c71a773a8ba17ec0bca03b6b7" + [[package]] name = "filetime" version = "0.2.23" @@ -3143,6 +3254,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -3263,7 +3375,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" dependencies = [ - "ff", + "ff 0.12.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.0", "rand_core 0.6.4", "subtle", ] @@ -3612,6 +3735,7 @@ checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", "hashbrown 0.14.3", + "serde", ] [[package]] @@ -4542,6 +4666,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.12", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.8", + "thiserror", + "url", +] + [[package]] name = "object" version = "0.32.2" @@ -4625,6 +4769,38 @@ dependencies = [ "serde_json", ] +[[package]] +name = "openidconnect" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d6050f6a84b81f23c569f5607ad883293e57491036e318fafe6fc4895fadb1" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256 0.13.2", + "p384", + "rand 0.8.5", + "rsa 0.9.6", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.8", + "subtle", + "thiserror", + "url", +] + [[package]] name = "openssl" version = "0.10.62" @@ -4675,6 +4851,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "outref" version = "0.5.1" @@ -4693,8 +4878,32 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.14.8", + "elliptic-curve 0.12.3", + "sha2 0.10.8", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", + "sha2 0.10.8", +] + +[[package]] +name = "p384" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", "sha2 0.10.8", ] @@ -5056,6 +5265,12 @@ dependencies = [ "array-init-cursor", ] +[[package]] +name = "platforms" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" + [[package]] name = "pmutil" version = "0.5.3" @@ -5543,6 +5758,15 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -6130,6 +6354,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "riff" version = "1.0.1" @@ -6647,7 +6881,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" dependencies = [ - "base16ct", + "base16ct 0.1.1", "der 0.6.1", "generic-array", "pkcs8 0.9.0", @@ -6655,6 +6889,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.8", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -6728,6 +6976,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde-wasm-bindgen" version = "0.4.5" @@ -6872,6 +7130,35 @@ dependencies = [ "v8", ] +[[package]] +name = "serde_with" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +dependencies = [ + "base64 0.21.7", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.1.0", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +dependencies = [ + "darling 0.20.3", + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "serde_yaml" version = "0.9.30" @@ -7520,9 +7807,9 @@ dependencies = [ [[package]] name = "strsim" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "ccbca6f34534eb78dbee83f6b2c9442fea7113f43d9e80ea320f0972ae5dc08d" [[package]] name = "strum" @@ -9315,12 +9602,13 @@ dependencies = [ "hf-hub", "hmac", "hyper", - "itertools 0.12.0", + "itertools 0.9.0", "lazy_static", "magic-crypt", "mail-send", "mime_guess", "object_store", + "openidconnect", "polars", "polars-io", "prometheus", @@ -9405,7 +9693,7 @@ dependencies = [ "hex", "hmac", "hyper", - "itertools 0.12.0", + "itertools 0.9.0", "lazy_static", "magic-crypt", "prometheus", @@ -9464,7 +9752,7 @@ version = "1.245.1" dependencies = [ "anyhow", "gosyn", - "itertools 0.12.0", + "itertools 0.9.0", "lazy_static", "regex", "windmill-parser", @@ -9486,7 +9774,7 @@ name = "windmill-parser-py" version = "1.245.1" dependencies = [ "anyhow", - "itertools 0.12.0", + "itertools 0.9.0", "rustpython-parser", "serde_json", "windmill-parser", @@ -9498,7 +9786,7 @@ version = "1.245.1" dependencies = [ "anyhow", "async-recursion", - "itertools 0.12.0", + "itertools 0.9.0", "lazy_static", "phf 0.11.2", "regex", @@ -9563,14 +9851,14 @@ dependencies = [ "anyhow", "async-recursion", "axum", - "bigdecimal 0.4.2", + "bigdecimal 0.3.1", "chrono", "chrono-tz", "cron", "futures-core", "hex", "hmac", - "itertools 0.12.0", + "itertools 0.9.0", "lazy_static", "prometheus", "regex", @@ -9613,13 +9901,14 @@ dependencies = [ "gcp_auth", "git-version", "hex", - "itertools 0.12.0", + "itertools 0.9.0", "jsonwebtoken", "lazy_static", "mysql_async", "native-tls", "nix", "once_cell", + "openidconnect", "pem 3.0.3", "postgres-native-tls", "prometheus", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 16ff05491b..31d7bea8ee 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -216,3 +216,4 @@ aws-config = "1.0.3" polars = { version = "0.35.2", features = ["lazy", "parquet", "aws", "csv", "dtype-full"] } polars-io = { version = "0.35.2", features = ["csv"] } object_store = { version = "0.8.0", features = ["aws"] } +openidconnect = { version = "3.4.0" } diff --git a/backend/migrations/20240108130114_add_oidc.down.sql b/backend/migrations/20240108130114_add_oidc.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20240108130114_add_oidc.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20240108130114_add_oidc.up.sql b/backend/migrations/20240108130114_add_oidc.up.sql new file mode 100644 index 0000000000..4b8c8abbdf --- /dev/null +++ b/backend/migrations/20240108130114_add_oidc.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TABLE token ADD COLUMN IF NOT EXISTS job UUID; \ No newline at end of file diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 758b0e02ac..16e6d003eb 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -781,6 +781,7 @@ async fn handle_zombie_jobs "ephemeral-script", *SCRIPT_TOKEN_EXPIRY, &job.email, + &job.id, ) .await .expect("could not create job token"); diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 7a4448d4ad..33ba7fa915 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -300,7 +300,7 @@ mod suspend_resume { let second = completed.next().await.unwrap(); // print_job(second, &db).await; - let token = windmill_worker::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "").await.unwrap(); + let token = windmill_worker::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil()).await.unwrap(); let secret = reqwest::get(format!( "http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{second}/0?token={token}&approver=ruben" )) @@ -401,7 +401,7 @@ mod suspend_resume { /* ... and send a request resume it. */ let second = completed.next().await.unwrap(); - let token = windmill_worker::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "").await.unwrap(); + let token = windmill_worker::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil()).await.unwrap(); let secret = reqwest::get(format!( "http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{second}/0?token={token}" )) diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index e0734c4394..cd2e6c49f9 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -9,7 +9,7 @@ name = "windmill_api" path = "src/lib.rs" [features] -enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise"] +enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "openidconnect"] stripe = ["async-stripe"] enterprise_saml = ["samael"] benchmark = [] @@ -89,3 +89,4 @@ aws-sdk-s3.workspace = true polars.workspace = true polars-io.workspace = true object_store.workspace = true +openidconnect = { workspace = true, optional = true} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5b81fa38e1..c7f72764aa 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1681,6 +1681,29 @@ paths: items: $ref: "#/components/schemas/TruncatedToken" + /w/{workspace}/oidc/token/{audience}: + post: + summary: get OIDC token (ee only) + operationId: getOidcToken + tags: + - oidc + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: audience + in: path + required: true + schema: + type: string + + responses: + "200": + description: new oidc token + content: + text/plain: + schema: + type: string + + /w/{workspace}/variables/create: post: summary: create variable diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 7d7f49cbcc..2f4bdcb21c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -63,6 +63,8 @@ pub mod job_helpers; pub mod job_metrics; pub mod jobs; pub mod oauth2; + +mod oidc; mod openai; mod raw_apps; mod resources; @@ -212,7 +214,8 @@ pub async fn run_server( users::workspaced_service().layer(Extension(argon2.clone())), ) .nest("/variables", variables::workspaced_service()) - .nest("/workspaces", workspaces::workspaced_service()), + .nest("/workspaces", workspaces::workspaced_service()) + .nest("/oidc", oidc::workspaced_service()), ) .nest("/workspaces", workspaces::global_service()) .nest( @@ -235,6 +238,7 @@ pub async fn run_server( .route_layer(from_extractor::()) .route_layer(from_extractor::()) .nest("/jobs", jobs::global_root_service()) + .nest("/oidc", oidc::global_service()) .nest( "/saml", saml::global_service().layer(Extension(Arc::new(sp_extension.0))), diff --git a/backend/windmill-api/src/oidc.rs b/backend/windmill-api/src/oidc.rs new file mode 100644 index 0000000000..95ef4da450 --- /dev/null +++ b/backend/windmill-api/src/oidc.rs @@ -0,0 +1,296 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2023 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use std::process::Command; + +use anyhow; + +#[cfg(feature = "enterprise")] +use openidconnect::{ + core::{ + CoreClaimName, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreProviderMetadata, + CoreResponseType, CoreRsaPrivateSigningKey, CoreSubjectIdentifierType, + }, + AdditionalClaims, AuthUrl, EmptyAdditionalProviderMetadata, IssuerUrl, JsonWebKeyId, + JsonWebKeySetUrl, ResponseTypes, +}; + +#[cfg(feature = "enterprise")] +impl AdditionalClaims for JobClaim {} + +use crate::db::DB; +use axum::extract::Path; +use axum::routing::get; +use axum::Extension; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "enterprise")] +pub fn global_service() -> Router { + Router::new() + .route( + "/.well-known/openid-configuration", + get(openid_configuration), + ) + .route("/jwks", get(jwks)) +} +#[cfg(not(feature = "enterprise"))] +pub fn global_service() -> Router { + Router::new() +} + +#[cfg(not(feature = "enterprise"))] +pub fn workspaced_service() -> Router { + Router::new() +} + +#[cfg(feature = "enterprise")] +pub fn workspaced_service() -> Router { + Router::new().route("/token/:audience", get(gen_token)) +} + +#[derive(Debug, Clone, serde::Serialize)] +struct Keys { + private_key: String, +} + +async fn gen_pems(db: &DB) -> anyhow::Result { + let private_key_cmd = Command::new("openssl") + .arg("genrsa") + .arg("--traditional") + .arg("2048") + .output() + .expect("failed to execute process"); + + let private_key = String::from_utf8(private_key_cmd.stdout).unwrap(); + + tracing::debug!("Generated private key: {}", private_key); + let keys = Keys { private_key }; + + sqlx::query!( + "INSERT INTO global_settings (name, value) VALUES ('rsa_keys', $1)", + serde_json::to_value(&keys).unwrap() + ) + .execute(db) + .await?; + + Ok(keys) +} + +#[cfg(feature = "enterprise")] +async fn get_private_key(db: &DB) -> anyhow::Result { + let key = sqlx::query_scalar!( + "SELECT value->>'private_key' FROM global_settings WHERE name = 'rsa_keys'", + ) + .fetch_optional(db) + .await? + .flatten(); + + if let Some(key) = key { + return Ok(key); + } else { + let keys = gen_pems(db).await?; + return Ok(keys.private_key); + } +} + +#[cfg(feature = "enterprise")] +pub async fn jwks( + Extension(db): Extension, +) -> windmill_common::error::JsonResult { + use openidconnect::PrivateSigningKey; + + let private_key = get_private_key(&db).await?; + let jwks = CoreJsonWebKeySet::new(vec![CoreRsaPrivateSigningKey::from_pem( + &private_key, + Some(JsonWebKeyId::new("windmill".to_string())), + ) + .map_err(|e| anyhow::anyhow!("Failed to parse PEM: {}", e))? + .as_verification_key()]); + + Ok(Json(jwks)) +} + +#[cfg(feature = "enterprise")] +pub async fn openid_configuration() -> windmill_common::error::JsonResult { + use windmill_common::BASE_URL; + + let base_url = BASE_URL.read().await.clone(); + return get_provider_metadata(base_url) + .map(Json) + .map_err(|e| e.into()); +} + +#[cfg(feature = "enterprise")] +pub fn get_provider_metadata(base_url: String) -> anyhow::Result { + let provider_metadata = CoreProviderMetadata::new( + IssuerUrl::new(format!("{base_url}/api/oidc/"))?, + AuthUrl::new(format!("{base_url}/api/oidc/"))?, + JsonWebKeySetUrl::new(format!("{base_url}/api/oidc/jwks"))?, + vec![ + // Optional: support the implicit flow. + ResponseTypes::new(vec![CoreResponseType::Token, CoreResponseType::IdToken]), // Other flows including hybrid flows may also be specified here. + ], + vec![CoreSubjectIdentifierType::Public], + vec![CoreJwsSigningAlgorithm::RsaSsaPssSha256], + EmptyAdditionalProviderMetadata {}, + ) + // Recommended: specify the supported ID token claims. + .set_claims_supported(Some(vec![ + // Providers may also define an enum instead of using CoreClaimName. + CoreClaimName::new("sub".to_string()), + CoreClaimName::new("aud".to_string()), + CoreClaimName::new("email".to_string()), + CoreClaimName::new("email_verified".to_string()), + CoreClaimName::new("exp".to_string()), + CoreClaimName::new("iat".to_string()), + CoreClaimName::new("iss".to_string()), + CoreClaimName::new("name".to_string()), + CoreClaimName::new("given_name".to_string()), + CoreClaimName::new("family_name".to_string()), + CoreClaimName::new("picture".to_string()), + CoreClaimName::new("locale".to_string()), + ])); + return Ok(provider_metadata); +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +struct JobClaim { + job_id: String, + path: Option, + flow_path: Option, + groups: Vec, + username: String, + email: String, + workspace: String, +} + +use crate::db::ApiAuthed; +use crate::users::Tokened; + +#[cfg(feature = "enterprise")] +pub async fn gen_token( + authed: ApiAuthed, + token: Tokened, + Extension(db): Extension, + Path((w_id, audience)): Path<(String, String)>, +) -> windmill_common::error::Result { + use chrono::{Duration, Utc}; + use openidconnect::{ + core::{CoreGenderClaim, CoreJsonWebKeyType, CoreJweContentEncryptionAlgorithm}, + Audience, EndUserEmail, IdToken, IdTokenClaims, StandardClaims, SubjectIdentifier, + }; + use windmill_queue::get_queued_job; + + use crate::users::get_groups_for_user; + + let private_key = get_private_key(&db).await?; + + let username = authed.username; + let email = authed.email; + + let job_id = { + let job = sqlx::query_scalar!("SELECT job FROM token WHERE token = $1", token.token) + .fetch_optional(&db) + .await? + .flatten(); + if job.is_none() { + return Err(anyhow::anyhow!("Token not found").into()); + } else { + job.unwrap() + } + }; + let mut tx = db.begin().await?; + let job = get_queued_job(job_id, &w_id, &mut tx).await?; + tx.commit().await?; + + let job = job.ok_or_else(|| anyhow::anyhow!("Queued job {} not found", job_id))?; + let issue_url = crate::BASE_URL.read().await.clone(); + let flow_path = if let Some(uuid) = job.parent_job { + sqlx::query_scalar!("SELECT script_path FROM queue WHERE id = $1", uuid) + .fetch_optional(&db) + .await? + .flatten() + } else { + None + }; + + let groups = get_groups_for_user(&w_id, &username, &email, &db) + .await + .ok() + .unwrap_or_default(); + + let id_token = IdToken::< + JobClaim, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + CoreJsonWebKeyType, + >::new( + IdTokenClaims::::new( + // Specify the issuer URL for the OpenID Connect Provider. + IssuerUrl::new(issue_url) + .map_err(|e| anyhow::anyhow!("Failed to generate IssueUrl: {}", e))?, + // The audience is usually a single entry with the client ID of the client for whom + // the ID token is intended. This is a required claim. + vec![Audience::new(audience)], + // The ID token expiration is usually much shorter than that of the access or refresh + // tokens issued to clients. + Utc::now() + Duration::seconds(300), + // The issue time is usually the current time. + Utc::now(), + // Set the standard claims defined by the OpenID Connect Core spec. + StandardClaims::new( + // Stable subject identifiers are recommended in place of e-mail addresses or other + // potentially unstable identifiers. This is the only required claim. + SubjectIdentifier::new("windmill".to_string()), + ) + // Optional: specify the user's e-mail address. This should only be provided if the + // client has been granted the 'profile' or 'email' scopes. + .set_email(Some(EndUserEmail::new(job.email.clone()))) + // Optional: specify whether the provider has verified the user's e-mail address. + .set_email_verified(Some(true)), + // OpenID Connect Providers may supply custom claims by providing a struct that + // implements the AdditionalClaims trait. This requires manually using the + // generic IdTokenClaims struct rather than the CoreIdTokenClaims type alias, + // however. + JobClaim { + job_id: job_id.to_string(), + path: job.script_path, + flow_path, + username: job.created_by, + email: job.email, + workspace: job.workspace_id, + groups, + }, + ), + // The private key used for signing the ID token. For confidential clients (those able + // to maintain a client secret), a CoreHmacKey can also be used, in conjunction + // with one of the CoreJwsSigningAlgorithm::HmacSha* signing algorithms. When using an + // HMAC-based signing algorithm, the UTF-8 representation of the client secret should + // be used as the HMAC key. + &CoreRsaPrivateSigningKey::from_pem( + &private_key, + Some(JsonWebKeyId::new("windmill".to_string())), + ) + .map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))?, + // Uses the RS256 signature algorithm. This crate supports any RS*, PS*, or HS* + // signature algorithm. + CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256, + // When returning the ID token alongside an access token (e.g., in the Authorization Code + // flow), it is recommended to pass the access token here to set the `at_hash` claim + // automatically. + None, + // When returning the ID token alongside an authorization code (e.g., in the implicit + // flow), it is recommended to pass the authorization code here to set the `c_hash` claim + // automatically. + None, + ) + .map_err(|e| anyhow::anyhow!("Failed to generate token: {}", e))?; + Ok(id_token.to_string()) +} diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index 8093853808..d6451c4d41 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -544,6 +544,8 @@ pub async fn transform_json_value<'c>( flow_path, job.schedule_path.clone(), job.flow_step_id.clone(), + job.root_job.map(|x| x.to_string()), + None, ) .await; diff --git a/backend/windmill-api/src/variables.rs b/backend/windmill-api/src/variables.rs index 758ba63677..b419f37c3b 100644 --- a/backend/windmill-api/src/variables.rs +++ b/backend/windmill-api/src/variables.rs @@ -69,6 +69,8 @@ async fn list_contextual_variables( Some("u/user/encapsulating_flow_path".to_string()), Some("u/user/triggering_flow_path".to_string()), Some("c".to_string()), + Some("017e0ad5-f499-73b6-5488-92a61c5196dd".to_string()), + Some("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c".to_string()), ) .await .to_vec(), diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 15799d17e4..73098b399c 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -144,7 +144,9 @@ pub async fn get_reserved_variables( flow_path: Option, schedule_path: Option, step_id: Option, -) -> [ContextualVariable; 15] { + root_flow_id: Option, + jwt_token: Option, +) -> [ContextualVariable; 17] { let state_path = { let trigger = if schedule_path.is_some() { username.to_string() @@ -233,11 +235,17 @@ pub async fn get_reserved_variables( value: flow_id.unwrap_or_else(|| "".to_string()), description: "Job id of the encapsulating flow if the job is a flow step".to_string(), }, + ContextualVariable { + name: "WM_ROOT_FLOW_JOB_ID".to_string(), + value: root_flow_id.unwrap_or_else(|| "".to_string()), + description: "Job id of the root flow if the job is a flow step".to_string(), + }, ContextualVariable { name: "WM_FLOW_PATH".to_string(), value: flow_path.unwrap_or_else(|| "".to_string()), description: "Path of the encapsulating flow if the job is a flow step".to_string(), }, + ContextualVariable { name: "WM_SCHEDULE_PATH".to_string(), value: schedule_path.unwrap_or_else(|| "".to_string()), @@ -269,6 +277,11 @@ pub async fn get_reserved_variables( name: "WM_OBJECT_PATH".to_string(), value: object_path, description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(), - } + }, + ContextualVariable { + name: "WM_OIDC_JWT".to_string(), + value: jwt_token.unwrap_or_else(|| "".to_string()), + description: "OIDC JWT token (EE only)".to_string(), + }, ] } diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index de8fd33767..2a9bd40b89 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [features] default = [] -enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "dep:gcp_auth", "dep:jsonwebtoken", "dep:pem", "dep:sha2", "dep:tiberius", "dep:tokio-util"] +enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "dep:gcp_auth", "dep:jsonwebtoken", "dep:openidconnect", "dep:pem", "dep:sha2", "dep:tiberius", "dep:tokio-util"] benchmark = ["windmill-queue/benchmark"] flamegraph = [] @@ -78,6 +78,7 @@ reqwest.workspace = true hex.workspace = true tiberius = { workspace = true, optional = true } tokio-util = { workspace = true, optional = true } +openidconnect = { workspace = true, optional = true} [build-dependencies] deno_fetch.workspace = true diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 3d5a2df11e..9055a02d0f 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -733,6 +733,8 @@ pub async fn start_worker( None, None, None, + None, + None, ) .await; let context_envs = build_envs_map(context.to_vec()).await; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 4758e3a001..35cf2c25ad 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -274,6 +274,8 @@ pub async fn transform_json_value( flow_path, job.schedule_path.clone(), job.flow_step_id.clone(), + job.root_job.clone().map(|x| x.to_string()), + None, ) .await; @@ -383,6 +385,8 @@ pub async fn get_reserved_variables( flow_path, job.schedule_path.clone(), job.flow_step_id.clone(), + job.root_job.clone().map(|x| x.to_string()), + None, ) .await .to_vec(); diff --git a/backend/windmill-worker/src/dedicated_worker.rs b/backend/windmill-worker/src/dedicated_worker.rs index 62e6a6a24f..7f1c926c2b 100644 --- a/backend/windmill-worker/src/dedicated_worker.rs +++ b/backend/windmill-worker/src/dedicated_worker.rs @@ -48,7 +48,7 @@ pub async fn handle_dedicated_process( job_dir: &str, context_envs: HashMap, envs: HashMap, - reserved_variables: [variables::ContextualVariable; 15], + reserved_variables: [variables::ContextualVariable; 17], common_bun_proc_envs: HashMap, args: Vec<&str>, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index c0057b2343..c85066b1a4 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -423,6 +423,8 @@ pub async fn start_worker( None, None, None, + None, + None, ) .await; let context_envs = build_envs_map(context.to_vec()).await; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index ee0743ea71..249e4d1a76 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -888,6 +888,8 @@ pub async fn start_worker( None, None, None, + None, + None, ) .await .to_vec(); @@ -1005,6 +1007,8 @@ for line in sys.stdin: None, None, None, + None, + None, ) .await; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 5dae852ff8..485443f6ac 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -113,6 +113,7 @@ pub async fn create_token_for_owner_in_bg( let w_id = job.workspace_id.clone(); let owner = job.permissioned_as.clone(); let email = job.email.clone(); + let job_id = job.id.clone(); tokio::spawn(async move { let token = create_token_for_owner( &db.clone(), @@ -121,6 +122,7 @@ pub async fn create_token_for_owner_in_bg( "ephemeral-script", *SCRIPT_TOKEN_EXPIRY, &email, + &job_id, ) .await .expect("could not create job token"); @@ -138,6 +140,7 @@ pub async fn create_token_for_owner( label: &str, expires_in: u64, email: &str, + job_id: &Uuid, ) -> error::Result { // TODO: Bad implementation. We should not have access to this DB here. if let Some(token) = JOB_TOKEN.as_ref() { @@ -156,15 +159,16 @@ pub async fn create_token_for_owner( sqlx::query_scalar!( "INSERT INTO token - (workspace_id, token, owner, label, expiration, super_admin, email) - VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6, $7)", + (workspace_id, token, owner, label, expiration, super_admin, email, job) + VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6, $7, $8)", &w_id, token, owner, label, expires_in.to_string(), is_super_admin, - email + email, + job_id ) .execute(db) .await?; diff --git a/frontend/src/lib/components/TestConnection.svelte b/frontend/src/lib/components/TestConnection.svelte index 332f800c63..a172e3a954 100644 --- a/frontend/src/lib/components/TestConnection.svelte +++ b/frontend/src/lib/components/TestConnection.svelte @@ -111,7 +111,8 @@ workspace: $workspaceStore!, id: job, requestBody: { - reason: 'Connection did not resolve after 5s' + reason: + 'Connection did not resolve after 5s. Do you have native workers or a worker group listening to the proper tag available?' } }) } catch (err) { diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index b20840d0a3..f4532493a5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -33,8 +33,14 @@ export const settings: Record = { fieldType: 'text', placeholder: 'https://windmill.com', storage: 'setting', + error: 'Base url must start with http:// or https:// and not end with / or a space', isValid: (value: string | undefined) => - value ? value?.startsWith('http') && value.includes('://') && !value?.endsWith('/') : true + value + ? value?.startsWith('http') && + value.includes('://') && + !value?.endsWith('/') && + !value?.endsWith(' ') + : true }, { label: 'Request Size Limit In MB', diff --git a/python-client/.gitignore b/python-client/.gitignore index e69de29bb2..0a6e1f82df 100644 --- a/python-client/.gitignore +++ b/python-client/.gitignore @@ -0,0 +1 @@ +windmill-api/ diff --git a/python-client/wmill/dist/wmill-1.245.0-py3-none-any.whl b/python-client/wmill/dist/wmill-1.245.0-py3-none-any.whl new file mode 100644 index 0000000000..f361ba393a Binary files /dev/null and b/python-client/wmill/dist/wmill-1.245.0-py3-none-any.whl differ diff --git a/python-client/wmill/dist/wmill-1.245.0.tar.gz b/python-client/wmill/dist/wmill-1.245.0.tar.gz new file mode 100644 index 0000000000..28736b1550 Binary files /dev/null and b/python-client/wmill/dist/wmill-1.245.0.tar.gz differ diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 1daf1f3d02..b965bd98b9 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -219,6 +219,9 @@ class Windmill: def get_job(self, job_id: str) -> dict: return self.get(f"/w/{self.workspace}/jobs_u/get/{job_id}").json() + def get_id_token(self, audience: str) -> dict: + return self.get(f"/w/{self.workspace}/oidc/token/{audience}").text + def get_job_status(self, job_id: str) -> JobStatus: job = self.get_job(job_id) job_type = job.get("type", "") diff --git a/python-client/wmill_pg/dist/wmill_pg-1.245.0-py3-none-any.whl b/python-client/wmill_pg/dist/wmill_pg-1.245.0-py3-none-any.whl new file mode 100644 index 0000000000..5e487f5b88 Binary files /dev/null and b/python-client/wmill_pg/dist/wmill_pg-1.245.0-py3-none-any.whl differ diff --git a/python-client/wmill_pg/dist/wmill_pg-1.245.0.tar.gz b/python-client/wmill_pg/dist/wmill_pg-1.245.0.tar.gz new file mode 100644 index 0000000000..e539cc5f88 Binary files /dev/null and b/python-client/wmill_pg/dist/wmill_pg-1.245.0.tar.gz differ diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 75ebdff7c8..148b6c7af9 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1,6 +1,12 @@ -import { ResourceService, VariableService, JobService, HelpersService } from "./index"; +import { + ResourceService, + VariableService, + JobService, + HelpersService, +} from "./index"; import { OpenAPI } from "./index"; import type { DenoS3LightClientSettings } from "./index"; +import { OidcService } from "./src"; export { AdminService, @@ -269,18 +275,20 @@ export async function databaseUrlFromResource(path: string): Promise { // }); // } -export async function denoS3LightClientSettings(s3_resource_path: string | undefined): Promise { +export async function denoS3LightClientSettings( + s3_resource_path: string | undefined +): Promise { !clientSet && setClient(); const workspace = getWorkspace(); const s3Resource = await HelpersService.s3ResourceInfo({ workspace: workspace, requestBody: { - s3_resource_path: s3_resource_path - } + s3_resource_path: s3_resource_path, + }, }); let settings: DenoS3LightClientSettings = { ...s3Resource, - } + }; return settings; } @@ -316,6 +324,20 @@ export function getResumeEndpoints(approver?: string): Promise<{ return getResumeUrls(approver); } +/** + * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) + * @param audience audience of the token + * @returns jwt token + */ +export async function getIdToken(audience: string): Promise { + !clientSet && setClient(); + const workspace = getWorkspace(); + return await OidcService.getOidcToken({ + workspace, + audience, + }); +} + export function base64ToUint8Array(data: string): Uint8Array { return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); }