From f9a6035160c394d1c95a4d8b341ee5a582b73e83 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 00:19:46 -0700 Subject: [PATCH] Add native OpenID Connect for first-party services --- config/OpenID.pkl | 17 + dashboard/Cargo.lock | 1 + dashboard/Cargo.toml | 1 + dashboard/src/auth.rs | 8 +- dashboard/src/main.rs | 25 +- dashboard/src/mcp.rs | 24 +- dashboard/src/oidc.rs | 625 +++++++++++++++++++++++++++++++++++ tools/dashboard-oidc-test.py | 176 ++++++++++ tools/oidc-provider.py | 14 + tools/studio.py | 14 +- 10 files changed, 887 insertions(+), 18 deletions(-) create mode 100644 config/OpenID.pkl create mode 100644 dashboard/src/oidc.rs create mode 100644 tools/dashboard-oidc-test.py create mode 100644 tools/oidc-provider.py diff --git a/config/OpenID.pkl b/config/OpenID.pkl new file mode 100644 index 0000000000000000000000000000000000000000..33fdf96dc88976dd96028bf9471f3831761959e1 --- /dev/null +++ b/config/OpenID.pkl @@ -0,0 +1,17 @@ +module OpenID + +import "Service.pkl" as service +import "site.pkl" as site + +hostname: String = "snowglobe.\(site.domain)" +issuer: String = "https://\(hostname)" + +class Client extends service.Requirement { + alias: String = "oidc" + fixed provider = "snowglobe" + fixed kind = "client" + clientId: String(isNotEmpty) + name: String + redirectUris: Listing + usernameAliases: Mapping = new {} +} diff --git a/dashboard/Cargo.lock b/dashboard/Cargo.lock index 9fc05f11cb81d84eeb56cfe17e2d7a151ed563db..9300a90690c8b109e60ae8c9d0b1b015bd2c619e 100644 --- a/dashboard/Cargo.lock +++ b/dashboard/Cargo.lock @@ -729,6 +729,7 @@ dependencies = [ "chrono", "futures", "globset", + "openssl", "rand 0.9.5", "regex", "reqwest", diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 32b52b5dee99a6a334ea2b1df728ed4c41bec176..0d43b62eb3a91716c8e07d1ccc9fb9409677ae62 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -11,6 +11,7 @@ bytes = "1" chrono = "0.4" futures = "0.3" globset = "0.4" +openssl = "0.10" rand = "0.9" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs index 0059d50b74d9a1a085ff6df30af0ad67b909623c..f960a18c630dcf071a6001285a14b4d3d9936d37 100644 --- a/dashboard/src/auth.rs +++ b/dashboard/src/auth.rs @@ -42,7 +42,7 @@ fn row(db: &Connection, statement: &str, key: &str) -> Result { .transpose()? .unwrap_or(Value::Null)) } -fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result { +pub(crate) fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result { let value: Option = db .query_row( "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?", @@ -58,7 +58,7 @@ fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result Result { +pub(crate) fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result { db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?; let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?; if count >= 4096 { @@ -188,6 +188,7 @@ impl Store { CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; + oidc::initialise(&db)?; Ok(Self { db: Mutex::new(db), origin, @@ -545,6 +546,9 @@ impl Store { } pub async fn route(State(app): State>, request: Request) -> Result { + if request.uri().path().starts_with("/auth/oidc/") { + return Ok(oidc::route(State(app), request).await); + } let auth = &app.auth; let path = request.uri().path().to_owned(); let method = request.method().clone(); diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index d18c540467f39a4098b81cea4f60d361503fb465..989b06bada3f0397fb9b65317640592c52979637 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -8,6 +8,7 @@ mod host; mod index; mod mcp; mod observability; +mod oidc; mod relay; mod shale; mod storage; @@ -404,6 +405,13 @@ async fn main() -> std::result::Result<(), Box> { ) .map_err(|error| std::io::Error::other(error.message))?; if let Some(path) = std::env::args().skip(1).next() { + if path == "--oidc-client" { + let input = serde_json::from_reader(std::io::stdin())?; + let output = oidc::provision(&auth, input) + .map_err(|error| std::io::Error::other(error.message))?; + println!("{output}"); + return Ok(()); + } if path != "--import-accounts" { return Err(std::io::Error::other("Unknown dashboard argument.").into()); } @@ -479,6 +487,7 @@ async fn main() -> std::result::Result<(), Box> { let router = Router::new() .route("/api/{*path}", any(api)) .route("/auth/{*path}", any(auth::route)) + .route("/.well-known/openid-configuration", any(oidc::route)) .route("/oauth/{*path}", any(mcp::oauth)) .route("/.well-known/{*path}", any(mcp::oauth)) .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) @@ -511,7 +520,9 @@ async fn main() -> std::result::Result<(), Box> { request.headers_mut().remove("Studio-Proxy-Token"); } let path = request.uri().path().to_owned(); - let asset = path.starts_with("/assets/") || path.starts_with("/fonts/") || path == "/snowflake.svg"; + let asset = path.starts_with("/assets/") + || path.starts_with("/fonts/") + || path == "/snowflake.svg"; if app.auth.ready() && !mcp::public(&path) && !path.starts_with("/auth/") @@ -582,9 +593,15 @@ async fn main() -> std::result::Result<(), Box> { request.headers_mut().remove("if-none-match"); } let mut response = next.run(request).await; - response.headers_mut().insert("referrer-policy", "no-referrer".parse().unwrap()); - response.headers_mut().insert("x-content-type-options", "nosniff".parse().unwrap()); - response.headers_mut().insert("x-frame-options", "DENY".parse().unwrap()); + response + .headers_mut() + .insert("referrer-policy", "no-referrer".parse().unwrap()); + response + .headers_mut() + .insert("x-content-type-options", "nosniff".parse().unwrap()); + response + .headers_mut() + .insert("x-frame-options", "DENY".parse().unwrap()); if asset && response.status().is_success() { response.headers_mut().insert( "cache-control", diff --git a/dashboard/src/mcp.rs b/dashboard/src/mcp.rs index d1345e7318e05b8e8b958165eb1cf566b2178671..83fae79198c6c91ba79ebad4bd2439b978d50186 100644 --- a/dashboard/src/mcp.rs +++ b/dashboard/src/mcp.rs @@ -498,7 +498,9 @@ pub(crate) fn active_owner(app: &App, grant: &Value) -> Result { }; Ok(profile["enabled"] == true && (grant["resource"] != app.mcp.resource("observability") - || array(&profile["groups"]).iter().any(|role| role["name"] == "infra-admin"))) + || array(&profile["groups"]) + .iter() + .any(|role| role["name"] == "infra-admin"))) } pub fn router( app: Arc, @@ -539,9 +541,16 @@ pub fn router( return Error::new(403, "This origin cannot use the connector.") .into_response(); } - match app.mcp.authenticate(request.headers(), &resource).and_then(|grant| { - if active_owner(&app, &grant)? { Ok(grant) } else { Err(Error::new(401, "invalid_token")) } - }) { + match app + .mcp + .authenticate(request.headers(), &resource) + .and_then(|grant| { + if active_owner(&app, &grant)? { + Ok(grant) + } else { + Err(Error::new(401, "invalid_token")) + } + }) { Ok(grant) => { request.extensions_mut().insert(Grant(grant)); if let Some(value) = request.headers_mut().get_mut("authorization") { @@ -565,7 +574,8 @@ pub fn router( } pub fn public(path: &str) -> bool { - path.starts_with("/oauth/") + path == "/.well-known/openid-configuration" + || path.starts_with("/oauth/") || CATALOGS.iter().any(|(id, _, _)| { path == format!("/mcp/{id}") || path.starts_with(&format!("/mcp/{id}/")) }) @@ -743,7 +753,9 @@ pub async fn manage( .collect() } else if shale_consent { let available = if body["resources"] == "all" { - shale::verified_session(&app, owner_id).await.map(|_| Vec::new()) + shale::verified_session(&app, owner_id) + .await + .map(|_| Vec::new()) } else { shale::repositories(&app, owner_id).await }; diff --git a/dashboard/src/oidc.rs b/dashboard/src/oidc.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5cf21bce53f932c515596ce8b47132e5377d141 --- /dev/null +++ b/dashboard/src/oidc.rs @@ -0,0 +1,625 @@ +use crate::*; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa, sign::Signer}; +use rusqlite::{Connection, OptionalExtension, params as sql}; +use sha2::{Digest, Sha256}; + +const SCOPES: &[&str] = &["openid", "profile", "email", "groups", "offline_access"]; +const ACCESS_TTL: i64 = 300; + +pub fn initialise(db: &Connection) -> Result<()> { + db.execute_batch("CREATE TABLE IF NOT EXISTS oidc_clients (id TEXT PRIMARY KEY, secret_hash TEXT NOT NULL, config TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS oidc_key (id INTEGER PRIMARY KEY CHECK(id=1), pem TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS oidc_tokens (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,session_hash TEXT NOT NULL REFERENCES sessions(hash) ON DELETE CASCADE,expires INTEGER NOT NULL,family TEXT NOT NULL,scope TEXT NOT NULL,auth_time INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS oidc_token_family ON oidc_tokens(family);")?; + let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM oidc_key)", [], |r| r.get(0))?; + if !exists { + let pem = PKey::from_rsa(Rsa::generate(2048)?)?.private_key_to_pem_pkcs8()?; + db.execute( + "INSERT OR IGNORE INTO oidc_key VALUES (1,?)", + [String::from_utf8(pem)?], + )?; + } + Ok(()) +} + +fn invalid(code: &str) -> Error { + Error::new(400, code) +} +fn fields(value: &str) -> Result> { + let mut fields = HashMap::new(); + for (name, value) in url::form_urlencoded::parse(value.as_bytes()) { + if fields + .insert(name.into_owned(), value.into_owned()) + .is_some() + { + return Err(invalid("invalid_request")); + } + } + Ok(fields) +} +fn client(db: &Connection, id: &str) -> Result { + let value: Option = db + .query_row("SELECT config FROM oidc_clients WHERE id=?", [id], |r| { + r.get(0) + }) + .optional()?; + value + .map(|s| serde_json::from_str(&s).map_err(Error::from)) + .transpose()? + .ok_or_else(|| invalid("invalid_client")) +} + +pub fn username(auth: &auth::Store, user_id: &str, client_id: &str) -> Result { + let db = auth.db.lock().unwrap(); + let user = auth::user(&db, user_id)?; + if user["enabled"] != true { + return Err(Error::new(403, "This account is disabled.")); + } + let config = client(&db, client_id)?; + let username = string(&user["username"]); + Ok(config["usernameAliases"][username] + .as_str() + .unwrap_or(username) + .to_owned()) +} + +/// Only the root-owned deployment CLI can register first-party clients. +pub fn provision(auth: &auth::Store, input: Value) -> Result { + let request = &input["request"]; + let id = string(&request["clientId"]); + let stage = string(&input["stageId"]); + if id.is_empty() || id.len() > 128 || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(invalid("invalid_client")); + } + let mut db = auth.db.lock().unwrap(); + let tx = db.transaction()?; + if input["operation"] == "delete" { + if stage.is_empty() || id != stage { + return Err(invalid("invalid_client")); + } + tx.execute("DELETE FROM oidc_clients WHERE id=?", [id])?; + tx.commit()?; + return Ok(json!({})); + } + let redirects = array(&request["redirectUris"]); + if redirects.is_empty() || redirects.len() > 64 { + return Err(invalid("invalid_redirect_uri")); + } + for value in redirects { + let value = value + .as_str() + .ok_or_else(|| invalid("invalid_redirect_uri"))?; + let uri = url::Url::parse(value).map_err(|_| invalid("invalid_redirect_uri"))?; + let site = auth + .origin + .host_str() + .unwrap() + .strip_prefix("snowglobe.") + .unwrap_or_default(); + if uri.scheme() != "https" + || site.is_empty() + || !uri + .host_str() + .is_some_and(|h| h.ends_with(&format!(".{site}"))) + || !uri.username().is_empty() + || uri.password().is_some() + || uri.fragment().is_some() + || value.contains('*') + { + return Err(invalid("invalid_redirect_uri")); + } + } + if !stage.is_empty() + && (id != stage + || redirects.iter().any(|v| { + url::Url::parse(string(v)) + .ok() + .and_then(|u| u.host_str().map(str::to_owned)) + .is_none_or(|h| !h.starts_with(&format!("{stage}."))) + })) + { + return Err(invalid("invalid_redirect_uri")); + } + let aliases = request["usernameAliases"] + .as_object() + .cloned() + .unwrap_or_default(); + let mut unique = std::collections::HashSet::new(); + for (name, alias) in &aliases { + let alias = alias + .as_str() + .filter(|a| !a.is_empty()) + .ok_or_else(|| invalid("invalid_alias"))?; + let matches: i64 = + tx.query_row("SELECT count(*) FROM users WHERE username=?", [name], |r| { + r.get(0) + })?; + if matches != 1 || !unique.insert(alias) { + return Err(invalid("invalid_alias")); + } + } + let previous = &input["existing"]; + if previous["clientId"].as_str().is_some_and(|old| old != id) { + return Err(invalid("invalid_client")); + } + let secret = previous["clientSecret"] + .as_str() + .filter(|s| s.len() >= 24) + .map(str::to_owned) + .unwrap_or_else(mcp::secret); + let config = + json!({"name":request["name"], "redirectUris":redirects, "usernameAliases":aliases}); + let old: Option<(String, String)> = tx + .query_row( + "SELECT secret_hash,config FROM oidc_clients WHERE id=?", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + if old + .as_ref() + .is_some_and(|old| old != &(mcp::hash(&secret), config.to_string())) + { + tx.execute("DELETE FROM oidc_tokens WHERE client_id=?", [id])?; + tx.execute( + "DELETE FROM pending WHERE kind='oidc-code' AND json_extract(data,'$.client')=?", + [id], + )?; + } + tx.execute("INSERT INTO oidc_clients VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET secret_hash=excluded.secret_hash,config=excluded.config", sql![id,mcp::hash(&secret),config.to_string()])?; + tx.commit()?; + Ok( + json!({"clientId":id,"clientSecret":secret,"issuerUrl":auth.origin.origin().ascii_serialization()}), + ) +} + +fn public_key(db: &Connection) -> Result<(PKey, String)> { + let pem: String = db.query_row("SELECT pem FROM oidc_key WHERE id=1", [], |r| r.get(0))?; + let key = PKey::private_key_from_pem(pem.as_bytes())?; + let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(key.public_key_to_der()?)); + Ok((key, kid)) +} +fn jwt(db: &Connection, claims: &Value) -> Result { + let (key, kid) = public_key(db)?; + let data = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec( + &json!({"alg":"RS256","typ":"JWT","kid":kid}) + )?), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?) + ); + let mut signer = Signer::new(MessageDigest::sha256(), &key)?; + signer.update(data.as_bytes())?; + Ok(format!( + "{data}.{}", + URL_SAFE_NO_PAD.encode(signer.sign_to_vec()?) + )) +} +fn claims(user: &Value, config: &Value, scopes: &str) -> Value { + let mut value = json!({"sub":user["id"]}); + let scopes: Vec<_> = scopes.split_whitespace().collect(); + if scopes.contains(&"profile") { + let username = string(&user["username"]); + value["preferred_username"] = config["usernameAliases"][username] + .as_str() + .map(|v| json!(v)) + .unwrap_or_else(|| json!(username)); + let name = format!("{} {}", string(&user["firstName"]), string(&user["lastName"])); + if !name.trim().is_empty() { value["name"] = json!(name.trim()); } + for (claim,field) in [("given_name","firstName"),("family_name","lastName")] { + if !string(&user[field]).is_empty() { value[claim] = user[field].clone(); } + } + } + if scopes.contains(&"email") && !string(&user["email"]).is_empty() { + value["email"] = user["email"].clone(); + value["email_verified"] = json!(user["emailVerified"] == true); + } + if scopes.contains(&"groups") { + value["groups"] = json!( + array(&user["groups"]) + .iter() + .map(|g| format!("role:{}", string(&g["name"]))) + .collect::>() + ); + } + value +} +fn eligible(db: &Connection, user_id: &str, session: &str) -> Result { + let valid: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE hash=? AND user_id=? AND client='dashboard' AND expires>?)",sql![session,user_id,now() as i64],|r|r.get(0))?; + let user = auth::user(db, user_id).map_err(|_| invalid("invalid_grant"))?; + if !valid || user["enabled"] != true || !array(&user["requiredActions"]).is_empty() { + return Err(invalid("invalid_grant")); + } + Ok(user) +} +fn authenticated_client( + db: &Connection, + headers: &HeaderMap, + form: &HashMap, +) -> Result { + let (id, secret) = if let Some(header) = headers.get("authorization") { + if form.contains_key("client_secret") { + return Err(invalid("invalid_request")); + } + let header = header + .to_str() + .ok() + .and_then(|h| h.strip_prefix("Basic ")) + .ok_or_else(|| invalid("invalid_client"))?; + let raw = String::from_utf8( + STANDARD + .decode(header) + .map_err(|_| invalid("invalid_client"))?, + ) + .map_err(|_| invalid("invalid_client"))?; + let (id, secret) = raw + .split_once(':') + .ok_or_else(|| invalid("invalid_client"))?; + let decode = |s: &str| -> String { + url::form_urlencoded::parse(format!("x={s}").as_bytes()) + .next() + .unwrap() + .1 + .into_owned() + }; + (decode(id), decode(secret)) + } else { + ( + form.get("client_id").cloned().unwrap_or_default(), + form.get("client_secret").cloned().unwrap_or_default(), + ) + }; + if secret.is_empty() + || form + .get("client_id") + .is_some_and(|supplied| supplied != &id) + { + return Err(invalid("invalid_client")); + } + let stored: Option = db + .query_row( + "SELECT secret_hash FROM oidc_clients WHERE id=?", + [&id], + |r| r.get(0), + ) + .optional()?; + if stored.is_none_or(|s| !bool::from(s.as_bytes().ct_eq(mcp::hash(&secret).as_bytes()))) { + return Err(invalid("invalid_client")); + } + Ok(id) +} +fn tokens(db: &Connection, auth: &auth::Store, data: &Value, nonce: Option<&str>) -> Result { + let user = eligible(db, string(&data["user"]), string(&data["session"]))?; + let config = client(db, string(&data["client"]))?; + let scope = string(&data["scope"]); + let access = mcp::secret(); + let family = string(&data["family"]); + let issued = now() as i64; + let mut value = claims(&user, &config, scope); + value["iss"] = json!(auth.origin.origin().ascii_serialization()); + value["aud"] = data["client"].clone(); + value["iat"] = json!(issued); + value["exp"] = json!(issued + ACCESS_TTL); + value["auth_time"] = data["auth_time"].clone(); + value["at_hash"] = json!(URL_SAFE_NO_PAD.encode(&Sha256::digest(access.as_bytes())[..16])); + if let Some(nonce) = nonce { + value["nonce"] = json!(nonce); + } + let id_token = jwt(db, &value)?; + let mut output = json!({"access_token":access,"token_type":"Bearer","expires_in":ACCESS_TTL,"id_token":id_token,"scope":scope}); + for (kind, token, expires) in [ + ("access", access, issued + ACCESS_TTL), + ("refresh", mcp::secret(), issued + 30 * 86400), + ] { + db.execute( + "INSERT INTO oidc_tokens VALUES (?,?,?,?,?,?,?,?,?)", + sql![ + mcp::hash(&token), + kind, + string(&data["user"]), + string(&data["client"]), + string(&data["session"]), + expires, + family, + scope, + data["auth_time"].as_i64().unwrap_or(issued) + ], + )?; + if kind == "refresh" { + output["refresh_token"] = json!(token); + } + } + Ok(output) +} + +async fn handle(app: &App, request: Request) -> Result { + let auth = &app.auth; + let path = request.uri().path().to_owned(); + let method = request.method().clone(); + let headers = request.headers().clone(); + let query = fields(request.uri().query().unwrap_or_default())?; + let issuer = auth.origin.origin().ascii_serialization(); + if path == "/.well-known/openid-configuration" && method == Method::GET { + return Ok(axum::Json(json!({"issuer":issuer,"authorization_endpoint":format!("{issuer}/auth/oidc/authorize"),"token_endpoint":format!("{issuer}/auth/oidc/token"),"userinfo_endpoint":format!("{issuer}/auth/oidc/userinfo"),"jwks_uri":format!("{issuer}/auth/oidc/jwks"),"revocation_endpoint":format!("{issuer}/auth/oidc/revoke"),"response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"code_challenge_methods_supported":["S256"],"scopes_supported":SCOPES,"claims_supported":["sub","preferred_username","name","given_name","family_name","email","email_verified","groups","auth_time","nonce"]})).into_response()); + } + if path == "/auth/oidc/jwks" && method == Method::GET { + let (key, kid) = public_key(&auth.db.lock().unwrap())?; + let rsa = key.rsa()?; + return Ok(axum::Json(json!({"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":kid,"n":URL_SAFE_NO_PAD.encode(rsa.n().to_vec()),"e":URL_SAFE_NO_PAD.encode(rsa.e().to_vec())}]})).into_response()); + } + if path == "/auth/oidc/authorize" && method == Method::GET { + let id = query + .get("client_id") + .map(String::as_str) + .unwrap_or_default(); + let redirect = query + .get("redirect_uri") + .map(String::as_str) + .unwrap_or_default(); + let config = client(&auth.db.lock().unwrap(), id)?; + if !array(&config["redirectUris"]) + .iter() + .any(|v| v.as_str() == Some(redirect)) + { + return Err(invalid("invalid_redirect_uri")); + } + if query.get("response_type").map(String::as_str) != Some("code") + || query.get("response_mode").is_some_and(|m| m != "query") + { + return Err(invalid("unsupported_response_type")); + } + let scope = query.get("scope").map(String::as_str).unwrap_or_default(); + if !scope.split_whitespace().any(|s| s == "openid") + || scope.split_whitespace().any(|s| !SCOPES.contains(&s)) + { + return Err(invalid("invalid_scope")); + } + let challenge = query.get("code_challenge"); + if challenge.is_some_and(|c| { + c.len() != 43 + || !c + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + }) || (challenge.is_some() + && query.get("code_challenge_method").map(String::as_str) != Some("S256")) + || (challenge.is_none() && query.contains_key("code_challenge_method")) + { + return Err(invalid("invalid_request")); + } + if query.values().any(|v| v.len() > 4096) { + return Err(invalid("invalid_request")); + } + let prompt = query.get("prompt").map(String::as_str).unwrap_or_default(); + if !matches!(prompt, "" | "none" | "login") { + return Err(invalid("invalid_request")); + } + let user = auth.session(&headers, "dashboard")?; + let session = mcp::hash(&auth::cookie(&headers, "__Host-snow-session").unwrap_or_default()); + let auth_time: i64 = auth + .db + .lock() + .unwrap() + .query_row( + "SELECT auth_time FROM sessions WHERE hash=?", + [&session], + |r| r.get(0), + ) + .optional()? + .unwrap_or(0); + let max_age = query + .get("max_age") + .map(|n| { + n.parse::() + .ok() + .filter(|n| *n >= 0) + .ok_or_else(|| invalid("invalid_request")) + }) + .transpose()?; + let reauth_token = query + .get("snow_reauth") + .map(String::as_str) + .unwrap_or_default(); + let reauth_state = + auth::pending(&auth.db.lock().unwrap(), reauth_token, "oidc-reauth", false)?; + let reauthed = reauth_state["client"] == id + && reauth_state["redirect"] == redirect + && reauth_state["session"] + .as_str() + .is_some_and(|old| old != session); + let reauth = prompt == "login" && !reauthed + || max_age.is_some_and(|age| now() as i64 - auth_time > age); + let mut target = url::Url::parse(redirect)?; + if user.is_null() || reauth { + if prompt == "none" { + target + .query_pairs_mut() + .append_pair("error", "login_required"); + if let Some(state) = query.get("state") { + target.query_pairs_mut().append_pair("state", state); + } + return Ok((StatusCode::FOUND, [("location", target.to_string())]).into_response()); + } + let mut next = auth.origin.join(&request.uri().to_string())?; + if prompt == "login" { + let token = if reauth_state["client"] == id && reauth_state["redirect"] == redirect + { + reauth_token.to_owned() + } else { + auth::issue( + &auth.db.lock().unwrap(), + "oidc-reauth", + json!({"client":id,"redirect":redirect,"session":session}), + 900, + )? + }; + let pairs: Vec<_> = next + .query_pairs() + .filter(|(key, _)| key != "snow_reauth") + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + next.set_query(None); + next.query_pairs_mut() + .extend_pairs(pairs) + .append_pair("snow_reauth", &token); + } + return Ok(( + StatusCode::FOUND, + [( + "location", + format!( + "/sign-in?next={}", + encoded(&format!( + "{}?{}", + next.path(), + next.query().unwrap_or_default() + )) + ), + )], + ) + .into_response()); + } + if !array(&user["requiredActions"]).is_empty() { + return Ok((StatusCode::FOUND, [("location", "/account")]).into_response()); + } + if reauthed { + auth::pending(&auth.db.lock().unwrap(), reauth_token, "oidc-reauth", true)?; + } + let data = json!({"user":user["id"],"client":id,"redirect":redirect,"scope":scope,"challenge":challenge,"nonce":query.get("nonce"),"session":session,"auth_time":auth_time,"family":mcp::secret()}); + let code = auth::issue(&auth.db.lock().unwrap(), "oidc-code", data, 60)?; + target.query_pairs_mut().append_pair("code", &code); + if let Some(state) = query.get("state") { + target.query_pairs_mut().append_pair("state", state); + } + return Ok((StatusCode::FOUND, [("location", target.to_string())]).into_response()); + } + if path == "/auth/oidc/userinfo" && matches!(method, Method::GET | Method::POST) { + let token = headers + .get("authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .ok_or_else(|| Error::new(401, "invalid_token"))?; + let db = auth.db.lock().unwrap(); + let data: Option<(String,String,String,String)> = db.query_row("SELECT user_id,client_id,session_hash,scope FROM oidc_tokens WHERE hash=? AND kind='access' AND expires>?",sql![mcp::hash(token),now() as i64],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?; + let (user, client_id, session, scope) = + data.ok_or_else(|| Error::new(401, "invalid_token"))?; + return Ok(axum::Json(claims( + &eligible(&db, &user, &session).map_err(|_| Error::new(401, "invalid_token"))?, + &client(&db, &client_id)?, + &scope, + )) + .into_response()); + } + if method != Method::POST || !matches!(path.as_str(), "/auth/oidc/token" | "/auth/oidc/revoke") + { + return Err(Error::new(404, "not_found")); + } + if headers + .get("content-type") + .and_then(|h| h.to_str().ok()) + .is_none_or(|t| t.split(';').next() != Some("application/x-www-form-urlencoded")) + { + return Err(invalid("invalid_request")); + } + let body = axum::body::to_bytes(request.into_body(), 16384).await?; + let form = fields(std::str::from_utf8(&body).map_err(|_| invalid("invalid_request"))?)?; + let mut db = auth.db.lock().unwrap(); + let tx = db.transaction()?; + let id = authenticated_client(&tx, &headers, &form)?; + if path.ends_with("/revoke") { + tx.execute("DELETE FROM oidc_tokens WHERE client_id=? AND family=(SELECT family FROM oidc_tokens WHERE hash=? AND client_id=?)",sql![id,mcp::hash(form.get("token").map(String::as_str).unwrap_or_default()),id])?; + tx.commit()?; + return Ok(StatusCode::OK.into_response()); + } + let grant = form + .get("grant_type") + .map(String::as_str) + .unwrap_or_default(); + let data = if grant == "authorization_code" { + let code = form.get("code").map(String::as_str).unwrap_or_default(); + let data = auth::pending(&tx, code, "oidc-code", false)?; + if data.is_null() + || data["client"] != id + || data["redirect"].as_str() != form.get("redirect_uri").map(String::as_str) + { + return Err(invalid("invalid_grant")); + } + if let Some(challenge) = data["challenge"].as_str() { + let verifier = form + .get("code_verifier") + .map(String::as_str) + .unwrap_or_default(); + if !(43..=128).contains(&verifier.len()) + || !verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-._~".contains(c)) + || URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) != challenge + { + return Err(invalid("invalid_grant")); + } + } + auth::pending(&tx, code, "oidc-code", true)?; + data + } else if grant == "refresh_token" { + let hash = mcp::hash( + form.get("refresh_token") + .map(String::as_str) + .unwrap_or_default(), + ); + let data: Option<(String,String,String,String,String,i64)> = tx.query_row("SELECT kind,user_id,session_hash,family,scope,auth_time FROM oidc_tokens WHERE hash=? AND client_id=? AND expires>?",sql![hash,id,now() as i64],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?; + let (kind, user, session, family, scope, auth_time) = + data.ok_or_else(|| invalid("invalid_grant"))?; + if kind == "refresh-used" { + tx.execute("DELETE FROM oidc_tokens WHERE family=?", [family])?; + tx.commit()?; + return Err(invalid("invalid_grant")); + } + if kind != "refresh" || form.get("scope").is_some_and(|s| s != &scope) { + return Err(invalid("invalid_grant")); + } + tx.execute( + "UPDATE oidc_tokens SET kind='refresh-used' WHERE hash=?", + [hash], + )?; + tx.execute( + "DELETE FROM oidc_tokens WHERE family=? AND kind='access'", + [&family], + )?; + json!({"user":user,"client":id,"session":session,"family":family,"scope":scope,"auth_time":auth_time}) + } else { + return Err(invalid("unsupported_grant_type")); + }; + tx.execute("DELETE FROM oidc_tokens WHERE expires<=?", [now() as i64])?; + let output = tokens(&tx, auth, &data, data["nonce"].as_str())?; + tx.commit()?; + Ok(axum::Json(output).into_response()) +} + +pub async fn route(State(app): State>, request: Request) -> Response { + let mut response = match handle(&app, request).await { + Ok(response) => response, + Err(error) => { + if error.status >= 500 { + eprintln!("OIDC: {}", error.message); + } + ( + StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + axum::Json( + json!({"error":if error.status>=500 {"server_error"} else {&error.message}}), + ), + ) + .into_response() + } + }; + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + response + .headers_mut() + .insert("pragma", "no-cache".parse().unwrap()); + response +} diff --git a/tools/dashboard-oidc-test.py b/tools/dashboard-oidc-test.py new file mode 100644 index 0000000000000000000000000000000000000000..cfd7fef9e09df0cb1b547b39febad4b3afeb28cc --- /dev/null +++ b/tools/dashboard-oidc-test.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Exercise native OIDC over HTTP with independently verified RSA signatures.""" +import argparse +import base64 +import hashlib +import http.client +import json +import os +from pathlib import Path +import socket +import sqlite3 +import subprocess +import tempfile +import time +import urllib.parse +import uuid +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + + +def b64(value): + return base64.urlsafe_b64encode(value).decode().rstrip('=') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--binary', type=Path, default=Path('dashboard/target/debug/home-dashboard')) + args = parser.parse_args() + origin = 'https://snowglobe.paperclover.net' + callback = 'https://shale.paperclover.net/-/callback' + actor, role = str(uuid.uuid4()), str(uuid.uuid4()) + password, secret, proof = uuid.uuid4().hex, uuid.uuid4().hex, uuid.uuid4().hex + uuid.uuid4().hex + salt = os.urandom(16) + digest = Argon2id(salt=salt, length=32, iterations=5, lanes=1, memory_cost=7168).derive(password.encode()) + export = {'rpId': 'auth.paperclover.net', 'roles': [{'id': role, 'name': 'infra-admin'}], 'users': [{ + 'id': actor, 'username': 'oidc-test', 'enabled': True, 'email': 'oidc-test@example.invalid', 'emailVerified': True, + 'firstName': 'OIDC', 'lastName': 'Test', 'createdTimestamp': 1, 'requiredActions': [], 'attributes': {}, 'roles': [role], + 'credentials': [{'id': str(uuid.uuid4()), 'type': 'password', 'createdDate': 1, + 'credentialData': {'algorithm': 'argon2', 'hashIterations': 5, 'additionalParameters': {'type': ['id'], 'memory': ['7168'], 'parallelism': ['1']}}, + 'secretData': {'salt': base64.b64encode(salt).decode(), 'value': base64.b64encode(digest).decode()}}]}]} + with tempfile.TemporaryDirectory(prefix='dashboard-oidc-') as temporary: + data = Path(temporary).resolve() + (data / 'proof').write_text(proof) + (data / 'source.json').write_text(json.dumps(export)) + environment = {**os.environ, 'STUDIO_DOMAIN': 'paperclover.net', 'STUDIO_DATA_DIR': str(data), + 'STUDIO_PUBLIC_ORIGIN': origin, 'STUDIO_AUTH_RP_ID': 'auth.paperclover.net', 'STUDIO_FILE_ORIGIN': 'https://file.paperclover.net', + 'STUDIO_WEB_DIR': str(Path('dashboard/dist').resolve()), 'STUDIO_PROXY_TOKEN_FILE': str(data / 'proof'), 'STUDIO_AUTH_REQUIRED': '1'} + binary = str(args.binary.resolve()) + result = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + def provision(client, redirects, aliases=None, status=0): + result = subprocess.run([binary, '--oidc-client'], env=environment, capture_output=True, text=True, + input=json.dumps({'request': {'kind': 'client', 'clientId': client, 'name': client, + 'redirectUris': redirects, 'usernameAliases': aliases or {}}, + 'existing': {'clientId': client, 'clientSecret': secret}})) + assert result.returncode == status, result.stderr + provision('shale', [callback], {'oidc-test': 'clover'}) + provision('other', ['https://jelly.paperclover.net/callback']) + provision('bad', ['https://evil.example/callback'], status=1) + provision('bad', ['https://shale.paperclover.net/*'], status=1) + with socket.socket() as available: + available.bind(('127.0.0.1', 0)); port = available.getsockname()[1] + environment['PORT'] = str(port) + log = (data / 'server.log').open('wb') + server = None + def start(): + nonlocal server + server = subprocess.Popen([binary], env=environment, stdout=log, stderr=log) + deadline = time.monotonic() + 15 + while True: + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): break + except OSError: + assert server.poll() is None, (data / 'server.log').read_text()[-1000:] + if time.monotonic() > deadline: raise AssertionError('dashboard did not start') + time.sleep(.05) + def stop(): + server.terminate(); server.wait(timeout=10) + cookies = {} + def request(path, method='GET', body=None, status=200, extra=None, session=True, form=False): + headers = {'Studio-Proxy-Token': proof, 'Host': 'snowglobe.paperclover.net', 'X-Studio-Client-IP': '127.0.0.1'} + if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'}) + if session: headers['Cookie'] = '; '.join(f'{k}={v}' for k,v in cookies.items()) + headers.update(extra or {}) + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15) + content = (urllib.parse.urlencode(body) if form else json.dumps(body)) if body is not None else None + connection.request(method, path, body=content, headers=headers) + response = connection.getresponse(); content = response.read(); fields = dict(response.getheaders()); connection.close() + assert response.status == status, (path, response.status, content[:200]) + if session and 'set-cookie' in fields: + key,value = fields['set-cookie'].split(';',1)[0].split('=',1); cookies[key] = value + return json.loads(content) if fields.get('content-type','').startswith('application/json') and content else fields + start() + try: + csrf = request('/auth/status')['csrf'] + login = {'csrf': csrf, 'username': 'oidc-test', 'password': password, 'next': '/'} + request('/auth/password', 'POST', login) + metadata = request('/.well-known/openid-configuration', extra={'Studio-Proxy-Token': 'wrong'}) + assert metadata['issuer'] == origin and metadata['id_token_signing_alg_values_supported'] == ['RS256'] + jwk = request('/auth/oidc/jwks')['keys'][0] + decode = lambda s: base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) + key = rsa.RSAPublicNumbers(int.from_bytes(decode(jwk['e']), 'big'), int.from_bytes(decode(jwk['n']), 'big')).public_key() + verifier = b64(os.urandom(32)) + authorize = {'client_id': 'shale', 'redirect_uri': callback, 'response_type': 'code', + 'scope': 'openid profile email groups', 'state': 'fixture-state', 'nonce': 'fixture-nonce', + 'code_challenge': b64(hashlib.sha256(verifier.encode()).digest()), 'code_challenge_method': 'S256'} + authorize_path = lambda values: '/auth/oidc/authorize?' + urllib.parse.urlencode(values) + request(authorize_path({**authorize, 'redirect_uri': 'https://evil.example/'}), status=400) + request(authorize_path(authorize) + '&client_id=other', status=400) + request(authorize_path({**authorize, 'scope': 'openid profile unknown'}), status=400) + assert request(authorize_path(authorize), session=False, status=302)['location'].startswith('/sign-in?next=') + denied = request(authorize_path({**authorize, 'prompt': 'none'}), session=False, status=302)['location'] + assert urllib.parse.parse_qs(urllib.parse.urlparse(denied).query)['error'] == ['login_required'] + def code(values=authorize): + location = request(authorize_path(values), status=302)['location'] + result = urllib.parse.parse_qs(urllib.parse.urlparse(location).query) + assert result['state'] == ['fixture-state'] + return result['code'][0] + exchange = {'client_id': 'shale', 'client_secret': secret, 'grant_type': 'authorization_code', + 'redirect_uri': callback, 'code': code(), 'code_verifier': verifier} + for change in [{'client_secret': 'wrong'}, {'client_id': 'other'}, {'code_verifier': 'wrong'}, {'redirect_uri': 'https://evil.example/'}]: + request('/auth/oidc/token', 'POST', {**exchange, **change}, status=400, form=True) + tokens = request('/auth/oidc/token', 'POST', exchange, form=True) + request('/auth/oidc/token', 'POST', exchange, status=400, form=True) + parts = tokens['id_token'].split('.') + key.verify(decode(parts[2]), (parts[0] + '.' + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256()) + claims = json.loads(decode(parts[1])); assert claims['iss'] == origin and claims['sub'] == actor and claims['aud'] == 'shale' + assert claims['nonce'] == 'fixture-nonce' and claims['preferred_username'] == 'clover' + assert claims['groups'] == ['role:infra-admin'] and claims['email_verified'] is True + assert claims['at_hash'] == b64(hashlib.sha256(tokens['access_token'].encode()).digest()[:16]) + bearer = lambda token: {'Authorization': 'Bearer ' + token} + assert request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']))['sub'] == actor + refresh = {'client_id': 'shale', 'client_secret': secret, 'grant_type': 'refresh_token', 'refresh_token': tokens['refresh_token']} + rotated = request('/auth/oidc/token', 'POST', refresh, form=True) + request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) + request('/auth/oidc/token', 'POST', refresh, form=True, status=400) + request('/auth/oidc/userinfo', extra=bearer(rotated['access_token']), status=401) + request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': rotated['refresh_token']}, form=True, status=400) + # Shale uses a confidential client without PKCE; basic client auth also works. + unbound = {k:v for k,v in authorize.items() if not k.startswith('code_challenge')} + basic = {'Authorization': 'Basic ' + base64.b64encode(('shale:' + secret).encode()).decode()} + tokens = request('/auth/oidc/token', 'POST', {'grant_type': 'authorization_code', 'code': code(unbound), 'redirect_uri': callback}, extra=basic, form=True) + request('/auth/oidc/revoke', 'POST', {'token': tokens['refresh_token']}, extra=basic, form=True) + request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) + # Reauthentication must use a new session, even when a caller spoofs the continuation. + forced = request(authorize_path({**unbound, 'prompt': 'login', 'snow_reauth': '1'}), status=302)['location'] + continuation = urllib.parse.parse_qs(urllib.parse.urlparse(forced).query)['next'][0] + assert request(continuation, status=302)['location'].startswith('/sign-in?next=') + request('/auth/password', 'POST', login) + resumed = request(continuation, status=302)['location'] + assert 'code=' in resumed and resumed.startswith(callback) + tokens = request('/auth/oidc/token', 'POST', {**exchange, 'code': code()}, form=True) + # Account disabling is checked at the token endpoint, not only at sign-in. + disabled_code = code() + db = sqlite3.connect(data / 'accounts.sqlite') + db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id=?", (actor,)); db.commit() + request('/auth/oidc/token', 'POST', {**exchange, 'code': disabled_code}, status=400, form=True) + request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) + db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('true')) WHERE id=?", (actor,)); db.commit(); db.close() + stop(); start() + assert request('/auth/oidc/jwks')['keys'][0] == jwk + request('/auth/oidc/userinfo', extra=bearer(tokens['access_token'])) + request('/auth/sign-out', 'POST', {}) + request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) + request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': tokens['refresh_token']}, form=True, status=400) + print(json.dumps({'signature_nonce_alias_claims': 'passed', 'client_redirect_pkce_binding': 'passed', + 'code_one_use': 'passed', 'refresh_rotation_reuse_revocation': 'passed', 'basic_client_auth': 'passed', + 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed'})) + finally: + if server and server.poll() is None: stop() + log.close() + + +if __name__ == '__main__': + main() diff --git a/tools/oidc-provider.py b/tools/oidc-provider.py new file mode 100644 index 0000000000000000000000000000000000000000..26e69b32d3d3ee791d156612f7d7cd2b0bf295a6 --- /dev/null +++ b/tools/oidc-provider.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Provision a first-party OIDC client through the dashboard's host-only CLI.""" +import json +import subprocess +import sys + +data = json.load(sys.stdin) +if data["request"]["kind"] != "client": + raise ValueError("unsupported Snowglobe input") +result = subprocess.run( + ["podman", "exec", "-i", "studio-dashboard", "/bin/home-dashboard", "--oidc-client"], + input=json.dumps(data), text=True, check=True, capture_output=True, +) +sys.stdout.write(result.stdout) diff --git a/tools/studio.py b/tools/studio.py index ae5adc61a7b216d3f19d31792269f8356cb8045b..696a29a33dcbf38246c5dca49a2e9a78d58cfb00 100644 --- a/tools/studio.py +++ b/tools/studio.py @@ -56,7 +56,7 @@ def service_dir(name): def dependencies(data): - return set(data["dependsOn"]) | {item["provider"] for item in data["inputs"].values()} + return set(data["dependsOn"]) | {item["provider"] for item in data["inputs"].values() if item["provider"] != "snowglobe"} def ordered(data): @@ -680,7 +680,8 @@ def provision_inputs(data, definitions, stage_id=None, postgres_source=None): for alias, request in data["inputs"].items(): if not NAME.fullmatch(alias): raise ValueError(f"invalid input alias: {alias}") - provider = definitions[request["provider"]] + native = request["provider"] == "snowglobe" + provider = {"id": "snowglobe", "provide": True, "containers": {}} if native else definitions[request["provider"]] if not provider.get("provide"): raise ValueError(f"{provider['id']} does not provide inputs") path = f"nomad/jobs/{data['id']}/inputs/{alias}" @@ -700,7 +701,7 @@ def provision_inputs(data, definitions, stage_id=None, postgres_source=None): payload["stageId"] = stage_id if postgres_source and provider["id"] == "postgres": payload["sourceContainer"] = postgres_source - script = config_path(provider["id"], provider["provide"]) + script = REPO / "tools/oidc-provider.py" if native else config_path(provider["id"], provider["provide"]) result = command("python3", str(script), input=json.dumps(payload), capture=True) values = json.loads(result.stdout) if not isinstance(values, dict) or not values or any( @@ -781,13 +782,14 @@ def bootstrap(all_data): def destroy_stage(stage, metadata, definitions): subprocess.run(["nomad", "job", "stop", "-purge", "-yes", stage], check=False) for alias, request in metadata["inputs"].items(): - provider = definitions[request["provider"]] + native = request["provider"] == "snowglobe" + provider = {"id": "snowglobe", "provide": True, "containers": {}} if native else definitions[request["provider"]] own = (get_variable("nomad/jobs/" + provider["id"]) or {}).get("Items", {}) variable = get_variable(f"nomad/jobs/{stage}/inputs/{alias}") existing = variable["Items"] if variable else None hosts = [task["http"]["hostname"] for task in provider["containers"].values() if task.get("http") and task["http"].get("hostname")] - script = config_path(provider["id"], provider["provide"]) + script = REPO / "tools/oidc-provider.py" if native else config_path(provider["id"], provider["provide"]) command("python3", str(script), input=json.dumps({ "operation": "delete", "request": request, "stageId": stage, "providerSecrets": own, "host": hosts[0] if hosts else None, @@ -963,7 +965,7 @@ def main(): parser.error("destroy requires a stage ID") token() metadata = json.loads((STATE / "stages" / f"{args.name}.json").read_text()) - names = {metadata["sourceId"]} | {request["provider"] for request in metadata["inputs"].values()} + names = {metadata["sourceId"]} | {request["provider"] for request in metadata["inputs"].values() if request["provider"] != "snowglobe"} definitions = {name: load(name, properties) for name in names} destroy_stage(args.name, metadata, definitions) return -- 2.54.0