authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 00:19:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logf9a6035160c394d1c95a4d8b341ee5a582b73e83
treea84193c3ea42fe7cbcb073dbbe75f5daa55a8ff6
parent60304498fc16cab0525ee3a8c0efb3233ce7a55a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Add native OpenID Connect for first-party services


10 files changed, 887 insertions(+), 18 deletions(-)

config/OpenID.pkl created+17
......@@ -0,0 +1,17 @@
1module OpenID
2
3import "Service.pkl" as service
4import "site.pkl" as site
5
6hostname: String = "snowglobe.\(site.domain)"
7issuer: String = "https://\(hostname)"
8
9class Client extends service.Requirement {
10 alias: String = "oidc"
11 fixed provider = "snowglobe"
12 fixed kind = "client"
13 clientId: String(isNotEmpty)
14 name: String
15 redirectUris: Listing<String>
16 usernameAliases: Mapping<String, String> = new {}
17}
dashboard/Cargo.lock+1
......@@ -729,6 +729,7 @@ dependencies = [
729729 "chrono",
730730 "futures",
731731 "globset",
732 "openssl",
732733 "rand 0.9.5",
733734 "regex",
734735 "reqwest",
dashboard/Cargo.toml+1
......@@ -11,6 +11,7 @@ bytes = "1"
1111chrono = "0.4"
1212futures = "0.3"
1313globset = "0.4"
14openssl = "0.10"
1415rand = "0.9"
1516regex = "1"
1617reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
dashboard/src/auth.rs+6-2
......@@ -42,7 +42,7 @@ fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> {
4242 .transpose()?
4343 .unwrap_or(Value::Null))
4444}
45fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result<Value> {
45pub(crate) fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result<Value> {
4646 let value: Option<String> = db
4747 .query_row(
4848 "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<Va
5858 .transpose()?
5959 .unwrap_or(Value::Null))
6060}
61fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result<String> {
61pub(crate) fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result<String> {
6262 db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?;
6363 let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?;
6464 if count >= 4096 {
......@@ -188,6 +188,7 @@ impl Store {
188188 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
189189 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);
190190 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;
191 oidc::initialise(&db)?;
191192 Ok(Self {
192193 db: Mutex::new(db),
193194 origin,
......@@ -545,6 +546,9 @@ impl Store {
545546}
546547
547548pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
549 if request.uri().path().starts_with("/auth/oidc/") {
550 return Ok(oidc::route(State(app), request).await);
551 }
548552 let auth = &app.auth;
549553 let path = request.uri().path().to_owned();
550554 let method = request.method().clone();
dashboard/src/main.rs+21-4
......@@ -8,6 +8,7 @@ mod host;
88mod index;
99mod mcp;
1010mod observability;
11mod oidc;
1112mod relay;
1213mod shale;
1314mod storage;
......@@ -404,6 +405,13 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
404405 )
405406 .map_err(|error| std::io::Error::other(error.message))?;
406407 if let Some(path) = std::env::args().skip(1).next() {
408 if path == "--oidc-client" {
409 let input = serde_json::from_reader(std::io::stdin())?;
410 let output = oidc::provision(&auth, input)
411 .map_err(|error| std::io::Error::other(error.message))?;
412 println!("{output}");
413 return Ok(());
414 }
407415 if path != "--import-accounts" {
408416 return Err(std::io::Error::other("Unknown dashboard argument.").into());
409417 }
......@@ -479,6 +487,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
479487 let router = Router::new()
480488 .route("/api/{*path}", any(api))
481489 .route("/auth/{*path}", any(auth::route))
490 .route("/.well-known/openid-configuration", any(oidc::route))
482491 .route("/oauth/{*path}", any(mcp::oauth))
483492 .route("/.well-known/{*path}", any(mcp::oauth))
484493 .nest_service("/assets", ServeDir::new(format!("{dist}/assets")))
......@@ -511,7 +520,9 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
511520 request.headers_mut().remove("Studio-Proxy-Token");
512521 }
513522 let path = request.uri().path().to_owned();
514 let asset = path.starts_with("/assets/") || path.starts_with("/fonts/") || path == "/snowflake.svg";
523 let asset = path.starts_with("/assets/")
524 || path.starts_with("/fonts/")
525 || path == "/snowflake.svg";
515526 if app.auth.ready()
516527 && !mcp::public(&path)
517528 && !path.starts_with("/auth/")
......@@ -582,9 +593,15 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
582593 request.headers_mut().remove("if-none-match");
583594 }
584595 let mut response = next.run(request).await;
585 response.headers_mut().insert("referrer-policy", "no-referrer".parse().unwrap());
586 response.headers_mut().insert("x-content-type-options", "nosniff".parse().unwrap());
587 response.headers_mut().insert("x-frame-options", "DENY".parse().unwrap());
596 response
597 .headers_mut()
598 .insert("referrer-policy", "no-referrer".parse().unwrap());
599 response
600 .headers_mut()
601 .insert("x-content-type-options", "nosniff".parse().unwrap());
602 response
603 .headers_mut()
604 .insert("x-frame-options", "DENY".parse().unwrap());
588605 if asset && response.status().is_success() {
589606 response.headers_mut().insert(
590607 "cache-control",
dashboard/src/mcp.rs+18-6
......@@ -498,7 +498,9 @@ pub(crate) fn active_owner(app: &App, grant: &Value) -> Result<bool> {
498498 };
499499 Ok(profile["enabled"] == true
500500 && (grant["resource"] != app.mcp.resource("observability")
501 || array(&profile["groups"]).iter().any(|role| role["name"] == "infra-admin")))
501 || array(&profile["groups"])
502 .iter()
503 .any(|role| role["name"] == "infra-admin")))
502504}
503505pub fn router<H: rmcp::ServerHandler>(
504506 app: Arc<App>,
......@@ -539,9 +541,16 @@ pub fn router<H: rmcp::ServerHandler>(
539541 return Error::new(403, "This origin cannot use the connector.")
540542 .into_response();
541543 }
542 match app.mcp.authenticate(request.headers(), &resource).and_then(|grant| {
543 if active_owner(&app, &grant)? { Ok(grant) } else { Err(Error::new(401, "invalid_token")) }
544 }) {
544 match app
545 .mcp
546 .authenticate(request.headers(), &resource)
547 .and_then(|grant| {
548 if active_owner(&app, &grant)? {
549 Ok(grant)
550 } else {
551 Err(Error::new(401, "invalid_token"))
552 }
553 }) {
545554 Ok(grant) => {
546555 request.extensions_mut().insert(Grant(grant));
547556 if let Some(value) = request.headers_mut().get_mut("authorization") {
......@@ -565,7 +574,8 @@ pub fn router<H: rmcp::ServerHandler>(
565574}
566575
567576pub fn public(path: &str) -> bool {
568 path.starts_with("/oauth/")
577 path == "/.well-known/openid-configuration"
578 || path.starts_with("/oauth/")
569579 || CATALOGS.iter().any(|(id, _, _)| {
570580 path == format!("/mcp/{id}") || path.starts_with(&format!("/mcp/{id}/"))
571581 })
......@@ -743,7 +753,9 @@ pub async fn manage(
743753 .collect()
744754 } else if shale_consent {
745755 let available = if body["resources"] == "all" {
746 shale::verified_session(&app, owner_id).await.map(|_| Vec::new())
756 shale::verified_session(&app, owner_id)
757 .await
758 .map(|_| Vec::new())
747759 } else {
748760 shale::repositories(&app, owner_id).await
749761 };
dashboard/src/oidc.rs created+625
......@@ -0,0 +1,625 @@
1use crate::*;
2use base64::{
3 Engine,
4 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
5};
6use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa, sign::Signer};
7use rusqlite::{Connection, OptionalExtension, params as sql};
8use sha2::{Digest, Sha256};
9
10const SCOPES: &[&str] = &["openid", "profile", "email", "groups", "offline_access"];
11const ACCESS_TTL: i64 = 300;
12
13pub fn initialise(db: &Connection) -> Result<()> {
14 db.execute_batch("CREATE TABLE IF NOT EXISTS oidc_clients (id TEXT PRIMARY KEY, secret_hash TEXT NOT NULL, config TEXT NOT NULL);
15 CREATE TABLE IF NOT EXISTS oidc_key (id INTEGER PRIMARY KEY CHECK(id=1), pem TEXT NOT NULL);
16 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);
17 CREATE INDEX IF NOT EXISTS oidc_token_family ON oidc_tokens(family);")?;
18 let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM oidc_key)", [], |r| r.get(0))?;
19 if !exists {
20 let pem = PKey::from_rsa(Rsa::generate(2048)?)?.private_key_to_pem_pkcs8()?;
21 db.execute(
22 "INSERT OR IGNORE INTO oidc_key VALUES (1,?)",
23 [String::from_utf8(pem)?],
24 )?;
25 }
26 Ok(())
27}
28
29fn invalid(code: &str) -> Error {
30 Error::new(400, code)
31}
32fn fields(value: &str) -> Result<HashMap<String, String>> {
33 let mut fields = HashMap::new();
34 for (name, value) in url::form_urlencoded::parse(value.as_bytes()) {
35 if fields
36 .insert(name.into_owned(), value.into_owned())
37 .is_some()
38 {
39 return Err(invalid("invalid_request"));
40 }
41 }
42 Ok(fields)
43}
44fn client(db: &Connection, id: &str) -> Result<Value> {
45 let value: Option<String> = db
46 .query_row("SELECT config FROM oidc_clients WHERE id=?", [id], |r| {
47 r.get(0)
48 })
49 .optional()?;
50 value
51 .map(|s| serde_json::from_str(&s).map_err(Error::from))
52 .transpose()?
53 .ok_or_else(|| invalid("invalid_client"))
54}
55
56pub fn username(auth: &auth::Store, user_id: &str, client_id: &str) -> Result<String> {
57 let db = auth.db.lock().unwrap();
58 let user = auth::user(&db, user_id)?;
59 if user["enabled"] != true {
60 return Err(Error::new(403, "This account is disabled."));
61 }
62 let config = client(&db, client_id)?;
63 let username = string(&user["username"]);
64 Ok(config["usernameAliases"][username]
65 .as_str()
66 .unwrap_or(username)
67 .to_owned())
68}
69
70/// Only the root-owned deployment CLI can register first-party clients.
71pub fn provision(auth: &auth::Store, input: Value) -> Result<Value> {
72 let request = &input["request"];
73 let id = string(&request["clientId"]);
74 let stage = string(&input["stageId"]);
75 if id.is_empty() || id.len() > 128 || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
76 {
77 return Err(invalid("invalid_client"));
78 }
79 let mut db = auth.db.lock().unwrap();
80 let tx = db.transaction()?;
81 if input["operation"] == "delete" {
82 if stage.is_empty() || id != stage {
83 return Err(invalid("invalid_client"));
84 }
85 tx.execute("DELETE FROM oidc_clients WHERE id=?", [id])?;
86 tx.commit()?;
87 return Ok(json!({}));
88 }
89 let redirects = array(&request["redirectUris"]);
90 if redirects.is_empty() || redirects.len() > 64 {
91 return Err(invalid("invalid_redirect_uri"));
92 }
93 for value in redirects {
94 let value = value
95 .as_str()
96 .ok_or_else(|| invalid("invalid_redirect_uri"))?;
97 let uri = url::Url::parse(value).map_err(|_| invalid("invalid_redirect_uri"))?;
98 let site = auth
99 .origin
100 .host_str()
101 .unwrap()
102 .strip_prefix("snowglobe.")
103 .unwrap_or_default();
104 if uri.scheme() != "https"
105 || site.is_empty()
106 || !uri
107 .host_str()
108 .is_some_and(|h| h.ends_with(&format!(".{site}")))
109 || !uri.username().is_empty()
110 || uri.password().is_some()
111 || uri.fragment().is_some()
112 || value.contains('*')
113 {
114 return Err(invalid("invalid_redirect_uri"));
115 }
116 }
117 if !stage.is_empty()
118 && (id != stage
119 || redirects.iter().any(|v| {
120 url::Url::parse(string(v))
121 .ok()
122 .and_then(|u| u.host_str().map(str::to_owned))
123 .is_none_or(|h| !h.starts_with(&format!("{stage}.")))
124 }))
125 {
126 return Err(invalid("invalid_redirect_uri"));
127 }
128 let aliases = request["usernameAliases"]
129 .as_object()
130 .cloned()
131 .unwrap_or_default();
132 let mut unique = std::collections::HashSet::new();
133 for (name, alias) in &aliases {
134 let alias = alias
135 .as_str()
136 .filter(|a| !a.is_empty())
137 .ok_or_else(|| invalid("invalid_alias"))?;
138 let matches: i64 =
139 tx.query_row("SELECT count(*) FROM users WHERE username=?", [name], |r| {
140 r.get(0)
141 })?;
142 if matches != 1 || !unique.insert(alias) {
143 return Err(invalid("invalid_alias"));
144 }
145 }
146 let previous = &input["existing"];
147 if previous["clientId"].as_str().is_some_and(|old| old != id) {
148 return Err(invalid("invalid_client"));
149 }
150 let secret = previous["clientSecret"]
151 .as_str()
152 .filter(|s| s.len() >= 24)
153 .map(str::to_owned)
154 .unwrap_or_else(mcp::secret);
155 let config =
156 json!({"name":request["name"], "redirectUris":redirects, "usernameAliases":aliases});
157 let old: Option<(String, String)> = tx
158 .query_row(
159 "SELECT secret_hash,config FROM oidc_clients WHERE id=?",
160 [id],
161 |r| Ok((r.get(0)?, r.get(1)?)),
162 )
163 .optional()?;
164 if old
165 .as_ref()
166 .is_some_and(|old| old != &(mcp::hash(&secret), config.to_string()))
167 {
168 tx.execute("DELETE FROM oidc_tokens WHERE client_id=?", [id])?;
169 tx.execute(
170 "DELETE FROM pending WHERE kind='oidc-code' AND json_extract(data,'$.client')=?",
171 [id],
172 )?;
173 }
174 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()])?;
175 tx.commit()?;
176 Ok(
177 json!({"clientId":id,"clientSecret":secret,"issuerUrl":auth.origin.origin().ascii_serialization()}),
178 )
179}
180
181fn public_key(db: &Connection) -> Result<(PKey<openssl::pkey::Private>, String)> {
182 let pem: String = db.query_row("SELECT pem FROM oidc_key WHERE id=1", [], |r| r.get(0))?;
183 let key = PKey::private_key_from_pem(pem.as_bytes())?;
184 let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(key.public_key_to_der()?));
185 Ok((key, kid))
186}
187fn jwt(db: &Connection, claims: &Value) -> Result<String> {
188 let (key, kid) = public_key(db)?;
189 let data = format!(
190 "{}.{}",
191 URL_SAFE_NO_PAD.encode(serde_json::to_vec(
192 &json!({"alg":"RS256","typ":"JWT","kid":kid})
193 )?),
194 URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?)
195 );
196 let mut signer = Signer::new(MessageDigest::sha256(), &key)?;
197 signer.update(data.as_bytes())?;
198 Ok(format!(
199 "{data}.{}",
200 URL_SAFE_NO_PAD.encode(signer.sign_to_vec()?)
201 ))
202}
203fn claims(user: &Value, config: &Value, scopes: &str) -> Value {
204 let mut value = json!({"sub":user["id"]});
205 let scopes: Vec<_> = scopes.split_whitespace().collect();
206 if scopes.contains(&"profile") {
207 let username = string(&user["username"]);
208 value["preferred_username"] = config["usernameAliases"][username]
209 .as_str()
210 .map(|v| json!(v))
211 .unwrap_or_else(|| json!(username));
212 let name = format!("{} {}", string(&user["firstName"]), string(&user["lastName"]));
213 if !name.trim().is_empty() { value["name"] = json!(name.trim()); }
214 for (claim,field) in [("given_name","firstName"),("family_name","lastName")] {
215 if !string(&user[field]).is_empty() { value[claim] = user[field].clone(); }
216 }
217 }
218 if scopes.contains(&"email") && !string(&user["email"]).is_empty() {
219 value["email"] = user["email"].clone();
220 value["email_verified"] = json!(user["emailVerified"] == true);
221 }
222 if scopes.contains(&"groups") {
223 value["groups"] = json!(
224 array(&user["groups"])
225 .iter()
226 .map(|g| format!("role:{}", string(&g["name"])))
227 .collect::<Vec<_>>()
228 );
229 }
230 value
231}
232fn eligible(db: &Connection, user_id: &str, session: &str) -> Result<Value> {
233 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))?;
234 let user = auth::user(db, user_id).map_err(|_| invalid("invalid_grant"))?;
235 if !valid || user["enabled"] != true || !array(&user["requiredActions"]).is_empty() {
236 return Err(invalid("invalid_grant"));
237 }
238 Ok(user)
239}
240fn authenticated_client(
241 db: &Connection,
242 headers: &HeaderMap,
243 form: &HashMap<String, String>,
244) -> Result<String> {
245 let (id, secret) = if let Some(header) = headers.get("authorization") {
246 if form.contains_key("client_secret") {
247 return Err(invalid("invalid_request"));
248 }
249 let header = header
250 .to_str()
251 .ok()
252 .and_then(|h| h.strip_prefix("Basic "))
253 .ok_or_else(|| invalid("invalid_client"))?;
254 let raw = String::from_utf8(
255 STANDARD
256 .decode(header)
257 .map_err(|_| invalid("invalid_client"))?,
258 )
259 .map_err(|_| invalid("invalid_client"))?;
260 let (id, secret) = raw
261 .split_once(':')
262 .ok_or_else(|| invalid("invalid_client"))?;
263 let decode = |s: &str| -> String {
264 url::form_urlencoded::parse(format!("x={s}").as_bytes())
265 .next()
266 .unwrap()
267 .1
268 .into_owned()
269 };
270 (decode(id), decode(secret))
271 } else {
272 (
273 form.get("client_id").cloned().unwrap_or_default(),
274 form.get("client_secret").cloned().unwrap_or_default(),
275 )
276 };
277 if secret.is_empty()
278 || form
279 .get("client_id")
280 .is_some_and(|supplied| supplied != &id)
281 {
282 return Err(invalid("invalid_client"));
283 }
284 let stored: Option<String> = db
285 .query_row(
286 "SELECT secret_hash FROM oidc_clients WHERE id=?",
287 [&id],
288 |r| r.get(0),
289 )
290 .optional()?;
291 if stored.is_none_or(|s| !bool::from(s.as_bytes().ct_eq(mcp::hash(&secret).as_bytes()))) {
292 return Err(invalid("invalid_client"));
293 }
294 Ok(id)
295}
296fn tokens(db: &Connection, auth: &auth::Store, data: &Value, nonce: Option<&str>) -> Result<Value> {
297 let user = eligible(db, string(&data["user"]), string(&data["session"]))?;
298 let config = client(db, string(&data["client"]))?;
299 let scope = string(&data["scope"]);
300 let access = mcp::secret();
301 let family = string(&data["family"]);
302 let issued = now() as i64;
303 let mut value = claims(&user, &config, scope);
304 value["iss"] = json!(auth.origin.origin().ascii_serialization());
305 value["aud"] = data["client"].clone();
306 value["iat"] = json!(issued);
307 value["exp"] = json!(issued + ACCESS_TTL);
308 value["auth_time"] = data["auth_time"].clone();
309 value["at_hash"] = json!(URL_SAFE_NO_PAD.encode(&Sha256::digest(access.as_bytes())[..16]));
310 if let Some(nonce) = nonce {
311 value["nonce"] = json!(nonce);
312 }
313 let id_token = jwt(db, &value)?;
314 let mut output = json!({"access_token":access,"token_type":"Bearer","expires_in":ACCESS_TTL,"id_token":id_token,"scope":scope});
315 for (kind, token, expires) in [
316 ("access", access, issued + ACCESS_TTL),
317 ("refresh", mcp::secret(), issued + 30 * 86400),
318 ] {
319 db.execute(
320 "INSERT INTO oidc_tokens VALUES (?,?,?,?,?,?,?,?,?)",
321 sql![
322 mcp::hash(&token),
323 kind,
324 string(&data["user"]),
325 string(&data["client"]),
326 string(&data["session"]),
327 expires,
328 family,
329 scope,
330 data["auth_time"].as_i64().unwrap_or(issued)
331 ],
332 )?;
333 if kind == "refresh" {
334 output["refresh_token"] = json!(token);
335 }
336 }
337 Ok(output)
338}
339
340async fn handle(app: &App, request: Request) -> Result<Response> {
341 let auth = &app.auth;
342 let path = request.uri().path().to_owned();
343 let method = request.method().clone();
344 let headers = request.headers().clone();
345 let query = fields(request.uri().query().unwrap_or_default())?;
346 let issuer = auth.origin.origin().ascii_serialization();
347 if path == "/.well-known/openid-configuration" && method == Method::GET {
348 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());
349 }
350 if path == "/auth/oidc/jwks" && method == Method::GET {
351 let (key, kid) = public_key(&auth.db.lock().unwrap())?;
352 let rsa = key.rsa()?;
353 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());
354 }
355 if path == "/auth/oidc/authorize" && method == Method::GET {
356 let id = query
357 .get("client_id")
358 .map(String::as_str)
359 .unwrap_or_default();
360 let redirect = query
361 .get("redirect_uri")
362 .map(String::as_str)
363 .unwrap_or_default();
364 let config = client(&auth.db.lock().unwrap(), id)?;
365 if !array(&config["redirectUris"])
366 .iter()
367 .any(|v| v.as_str() == Some(redirect))
368 {
369 return Err(invalid("invalid_redirect_uri"));
370 }
371 if query.get("response_type").map(String::as_str) != Some("code")
372 || query.get("response_mode").is_some_and(|m| m != "query")
373 {
374 return Err(invalid("unsupported_response_type"));
375 }
376 let scope = query.get("scope").map(String::as_str).unwrap_or_default();
377 if !scope.split_whitespace().any(|s| s == "openid")
378 || scope.split_whitespace().any(|s| !SCOPES.contains(&s))
379 {
380 return Err(invalid("invalid_scope"));
381 }
382 let challenge = query.get("code_challenge");
383 if challenge.is_some_and(|c| {
384 c.len() != 43
385 || !c
386 .chars()
387 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
388 }) || (challenge.is_some()
389 && query.get("code_challenge_method").map(String::as_str) != Some("S256"))
390 || (challenge.is_none() && query.contains_key("code_challenge_method"))
391 {
392 return Err(invalid("invalid_request"));
393 }
394 if query.values().any(|v| v.len() > 4096) {
395 return Err(invalid("invalid_request"));
396 }
397 let prompt = query.get("prompt").map(String::as_str).unwrap_or_default();
398 if !matches!(prompt, "" | "none" | "login") {
399 return Err(invalid("invalid_request"));
400 }
401 let user = auth.session(&headers, "dashboard")?;
402 let session = mcp::hash(&auth::cookie(&headers, "__Host-snow-session").unwrap_or_default());
403 let auth_time: i64 = auth
404 .db
405 .lock()
406 .unwrap()
407 .query_row(
408 "SELECT auth_time FROM sessions WHERE hash=?",
409 [&session],
410 |r| r.get(0),
411 )
412 .optional()?
413 .unwrap_or(0);
414 let max_age = query
415 .get("max_age")
416 .map(|n| {
417 n.parse::<i64>()
418 .ok()
419 .filter(|n| *n >= 0)
420 .ok_or_else(|| invalid("invalid_request"))
421 })
422 .transpose()?;
423 let reauth_token = query
424 .get("snow_reauth")
425 .map(String::as_str)
426 .unwrap_or_default();
427 let reauth_state =
428 auth::pending(&auth.db.lock().unwrap(), reauth_token, "oidc-reauth", false)?;
429 let reauthed = reauth_state["client"] == id
430 && reauth_state["redirect"] == redirect
431 && reauth_state["session"]
432 .as_str()
433 .is_some_and(|old| old != session);
434 let reauth = prompt == "login" && !reauthed
435 || max_age.is_some_and(|age| now() as i64 - auth_time > age);
436 let mut target = url::Url::parse(redirect)?;
437 if user.is_null() || reauth {
438 if prompt == "none" {
439 target
440 .query_pairs_mut()
441 .append_pair("error", "login_required");
442 if let Some(state) = query.get("state") {
443 target.query_pairs_mut().append_pair("state", state);
444 }
445 return Ok((StatusCode::FOUND, [("location", target.to_string())]).into_response());
446 }
447 let mut next = auth.origin.join(&request.uri().to_string())?;
448 if prompt == "login" {
449 let token = if reauth_state["client"] == id && reauth_state["redirect"] == redirect
450 {
451 reauth_token.to_owned()
452 } else {
453 auth::issue(
454 &auth.db.lock().unwrap(),
455 "oidc-reauth",
456 json!({"client":id,"redirect":redirect,"session":session}),
457 900,
458 )?
459 };
460 let pairs: Vec<_> = next
461 .query_pairs()
462 .filter(|(key, _)| key != "snow_reauth")
463 .map(|(k, v)| (k.into_owned(), v.into_owned()))
464 .collect();
465 next.set_query(None);
466 next.query_pairs_mut()
467 .extend_pairs(pairs)
468 .append_pair("snow_reauth", &token);
469 }
470 return Ok((
471 StatusCode::FOUND,
472 [(
473 "location",
474 format!(
475 "/sign-in?next={}",
476 encoded(&format!(
477 "{}?{}",
478 next.path(),
479 next.query().unwrap_or_default()
480 ))
481 ),
482 )],
483 )
484 .into_response());
485 }
486 if !array(&user["requiredActions"]).is_empty() {
487 return Ok((StatusCode::FOUND, [("location", "/account")]).into_response());
488 }
489 if reauthed {
490 auth::pending(&auth.db.lock().unwrap(), reauth_token, "oidc-reauth", true)?;
491 }
492 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()});
493 let code = auth::issue(&auth.db.lock().unwrap(), "oidc-code", data, 60)?;
494 target.query_pairs_mut().append_pair("code", &code);
495 if let Some(state) = query.get("state") {
496 target.query_pairs_mut().append_pair("state", state);
497 }
498 return Ok((StatusCode::FOUND, [("location", target.to_string())]).into_response());
499 }
500 if path == "/auth/oidc/userinfo" && matches!(method, Method::GET | Method::POST) {
501 let token = headers
502 .get("authorization")
503 .and_then(|h| h.to_str().ok())
504 .and_then(|h| h.strip_prefix("Bearer "))
505 .ok_or_else(|| Error::new(401, "invalid_token"))?;
506 let db = auth.db.lock().unwrap();
507 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()?;
508 let (user, client_id, session, scope) =
509 data.ok_or_else(|| Error::new(401, "invalid_token"))?;
510 return Ok(axum::Json(claims(
511 &eligible(&db, &user, &session).map_err(|_| Error::new(401, "invalid_token"))?,
512 &client(&db, &client_id)?,
513 &scope,
514 ))
515 .into_response());
516 }
517 if method != Method::POST || !matches!(path.as_str(), "/auth/oidc/token" | "/auth/oidc/revoke")
518 {
519 return Err(Error::new(404, "not_found"));
520 }
521 if headers
522 .get("content-type")
523 .and_then(|h| h.to_str().ok())
524 .is_none_or(|t| t.split(';').next() != Some("application/x-www-form-urlencoded"))
525 {
526 return Err(invalid("invalid_request"));
527 }
528 let body = axum::body::to_bytes(request.into_body(), 16384).await?;
529 let form = fields(std::str::from_utf8(&body).map_err(|_| invalid("invalid_request"))?)?;
530 let mut db = auth.db.lock().unwrap();
531 let tx = db.transaction()?;
532 let id = authenticated_client(&tx, &headers, &form)?;
533 if path.ends_with("/revoke") {
534 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])?;
535 tx.commit()?;
536 return Ok(StatusCode::OK.into_response());
537 }
538 let grant = form
539 .get("grant_type")
540 .map(String::as_str)
541 .unwrap_or_default();
542 let data = if grant == "authorization_code" {
543 let code = form.get("code").map(String::as_str).unwrap_or_default();
544 let data = auth::pending(&tx, code, "oidc-code", false)?;
545 if data.is_null()
546 || data["client"] != id
547 || data["redirect"].as_str() != form.get("redirect_uri").map(String::as_str)
548 {
549 return Err(invalid("invalid_grant"));
550 }
551 if let Some(challenge) = data["challenge"].as_str() {
552 let verifier = form
553 .get("code_verifier")
554 .map(String::as_str)
555 .unwrap_or_default();
556 if !(43..=128).contains(&verifier.len())
557 || !verifier
558 .chars()
559 .all(|c| c.is_ascii_alphanumeric() || "-._~".contains(c))
560 || URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) != challenge
561 {
562 return Err(invalid("invalid_grant"));
563 }
564 }
565 auth::pending(&tx, code, "oidc-code", true)?;
566 data
567 } else if grant == "refresh_token" {
568 let hash = mcp::hash(
569 form.get("refresh_token")
570 .map(String::as_str)
571 .unwrap_or_default(),
572 );
573 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()?;
574 let (kind, user, session, family, scope, auth_time) =
575 data.ok_or_else(|| invalid("invalid_grant"))?;
576 if kind == "refresh-used" {
577 tx.execute("DELETE FROM oidc_tokens WHERE family=?", [family])?;
578 tx.commit()?;
579 return Err(invalid("invalid_grant"));
580 }
581 if kind != "refresh" || form.get("scope").is_some_and(|s| s != &scope) {
582 return Err(invalid("invalid_grant"));
583 }
584 tx.execute(
585 "UPDATE oidc_tokens SET kind='refresh-used' WHERE hash=?",
586 [hash],
587 )?;
588 tx.execute(
589 "DELETE FROM oidc_tokens WHERE family=? AND kind='access'",
590 [&family],
591 )?;
592 json!({"user":user,"client":id,"session":session,"family":family,"scope":scope,"auth_time":auth_time})
593 } else {
594 return Err(invalid("unsupported_grant_type"));
595 };
596 tx.execute("DELETE FROM oidc_tokens WHERE expires<=?", [now() as i64])?;
597 let output = tokens(&tx, auth, &data, data["nonce"].as_str())?;
598 tx.commit()?;
599 Ok(axum::Json(output).into_response())
600}
601
602pub async fn route(State(app): State<Arc<App>>, request: Request) -> Response {
603 let mut response = match handle(&app, request).await {
604 Ok(response) => response,
605 Err(error) => {
606 if error.status >= 500 {
607 eprintln!("OIDC: {}", error.message);
608 }
609 (
610 StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
611 axum::Json(
612 json!({"error":if error.status>=500 {"server_error"} else {&error.message}}),
613 ),
614 )
615 .into_response()
616 }
617 };
618 response
619 .headers_mut()
620 .insert("cache-control", "no-store".parse().unwrap());
621 response
622 .headers_mut()
623 .insert("pragma", "no-cache".parse().unwrap());
624 response
625}
tools/dashboard-oidc-test.py created+176
......@@ -0,0 +1,176 @@
1#!/usr/bin/env python3
2"""Exercise native OIDC over HTTP with independently verified RSA signatures."""
3import argparse
4import base64
5import hashlib
6import http.client
7import json
8import os
9from pathlib import Path
10import socket
11import sqlite3
12import subprocess
13import tempfile
14import time
15import urllib.parse
16import uuid
17from cryptography.hazmat.primitives import hashes
18from cryptography.hazmat.primitives.asymmetric import rsa, padding
19from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
20
21
22def b64(value):
23 return base64.urlsafe_b64encode(value).decode().rstrip('=')
24
25
26def main():
27 parser = argparse.ArgumentParser()
28 parser.add_argument('--binary', type=Path, default=Path('dashboard/target/debug/home-dashboard'))
29 args = parser.parse_args()
30 origin = 'https://snowglobe.paperclover.net'
31 callback = 'https://shale.paperclover.net/-/callback'
32 actor, role = str(uuid.uuid4()), str(uuid.uuid4())
33 password, secret, proof = uuid.uuid4().hex, uuid.uuid4().hex, uuid.uuid4().hex + uuid.uuid4().hex
34 salt = os.urandom(16)
35 digest = Argon2id(salt=salt, length=32, iterations=5, lanes=1, memory_cost=7168).derive(password.encode())
36 export = {'rpId': 'auth.paperclover.net', 'roles': [{'id': role, 'name': 'infra-admin'}], 'users': [{
37 'id': actor, 'username': 'oidc-test', 'enabled': True, 'email': 'oidc-test@example.invalid', 'emailVerified': True,
38 'firstName': 'OIDC', 'lastName': 'Test', 'createdTimestamp': 1, 'requiredActions': [], 'attributes': {}, 'roles': [role],
39 'credentials': [{'id': str(uuid.uuid4()), 'type': 'password', 'createdDate': 1,
40 'credentialData': {'algorithm': 'argon2', 'hashIterations': 5, 'additionalParameters': {'type': ['id'], 'memory': ['7168'], 'parallelism': ['1']}},
41 'secretData': {'salt': base64.b64encode(salt).decode(), 'value': base64.b64encode(digest).decode()}}]}]}
42 with tempfile.TemporaryDirectory(prefix='dashboard-oidc-') as temporary:
43 data = Path(temporary).resolve()
44 (data / 'proof').write_text(proof)
45 (data / 'source.json').write_text(json.dumps(export))
46 environment = {**os.environ, 'STUDIO_DOMAIN': 'paperclover.net', 'STUDIO_DATA_DIR': str(data),
47 'STUDIO_PUBLIC_ORIGIN': origin, 'STUDIO_AUTH_RP_ID': 'auth.paperclover.net', 'STUDIO_FILE_ORIGIN': 'https://file.paperclover.net',
48 'STUDIO_WEB_DIR': str(Path('dashboard/dist').resolve()), 'STUDIO_PROXY_TOKEN_FILE': str(data / 'proof'), 'STUDIO_AUTH_REQUIRED': '1'}
49 binary = str(args.binary.resolve())
50 result = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True)
51 assert result.returncode == 0, result.stderr
52 def provision(client, redirects, aliases=None, status=0):
53 result = subprocess.run([binary, '--oidc-client'], env=environment, capture_output=True, text=True,
54 input=json.dumps({'request': {'kind': 'client', 'clientId': client, 'name': client,
55 'redirectUris': redirects, 'usernameAliases': aliases or {}},
56 'existing': {'clientId': client, 'clientSecret': secret}}))
57 assert result.returncode == status, result.stderr
58 provision('shale', [callback], {'oidc-test': 'clover'})
59 provision('other', ['https://jelly.paperclover.net/callback'])
60 provision('bad', ['https://evil.example/callback'], status=1)
61 provision('bad', ['https://shale.paperclover.net/*'], status=1)
62 with socket.socket() as available:
63 available.bind(('127.0.0.1', 0)); port = available.getsockname()[1]
64 environment['PORT'] = str(port)
65 log = (data / 'server.log').open('wb')
66 server = None
67 def start():
68 nonlocal server
69 server = subprocess.Popen([binary], env=environment, stdout=log, stderr=log)
70 deadline = time.monotonic() + 15
71 while True:
72 try:
73 with socket.create_connection(('127.0.0.1', port), timeout=.1): break
74 except OSError:
75 assert server.poll() is None, (data / 'server.log').read_text()[-1000:]
76 if time.monotonic() > deadline: raise AssertionError('dashboard did not start')
77 time.sleep(.05)
78 def stop():
79 server.terminate(); server.wait(timeout=10)
80 cookies = {}
81 def request(path, method='GET', body=None, status=200, extra=None, session=True, form=False):
82 headers = {'Studio-Proxy-Token': proof, 'Host': 'snowglobe.paperclover.net', 'X-Studio-Client-IP': '127.0.0.1'}
83 if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'})
84 if session: headers['Cookie'] = '; '.join(f'{k}={v}' for k,v in cookies.items())
85 headers.update(extra or {})
86 connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15)
87 content = (urllib.parse.urlencode(body) if form else json.dumps(body)) if body is not None else None
88 connection.request(method, path, body=content, headers=headers)
89 response = connection.getresponse(); content = response.read(); fields = dict(response.getheaders()); connection.close()
90 assert response.status == status, (path, response.status, content[:200])
91 if session and 'set-cookie' in fields:
92 key,value = fields['set-cookie'].split(';',1)[0].split('=',1); cookies[key] = value
93 return json.loads(content) if fields.get('content-type','').startswith('application/json') and content else fields
94 start()
95 try:
96 csrf = request('/auth/status')['csrf']
97 login = {'csrf': csrf, 'username': 'oidc-test', 'password': password, 'next': '/'}
98 request('/auth/password', 'POST', login)
99 metadata = request('/.well-known/openid-configuration', extra={'Studio-Proxy-Token': 'wrong'})
100 assert metadata['issuer'] == origin and metadata['id_token_signing_alg_values_supported'] == ['RS256']
101 jwk = request('/auth/oidc/jwks')['keys'][0]
102 decode = lambda s: base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
103 key = rsa.RSAPublicNumbers(int.from_bytes(decode(jwk['e']), 'big'), int.from_bytes(decode(jwk['n']), 'big')).public_key()
104 verifier = b64(os.urandom(32))
105 authorize = {'client_id': 'shale', 'redirect_uri': callback, 'response_type': 'code',
106 'scope': 'openid profile email groups', 'state': 'fixture-state', 'nonce': 'fixture-nonce',
107 'code_challenge': b64(hashlib.sha256(verifier.encode()).digest()), 'code_challenge_method': 'S256'}
108 authorize_path = lambda values: '/auth/oidc/authorize?' + urllib.parse.urlencode(values)
109 request(authorize_path({**authorize, 'redirect_uri': 'https://evil.example/'}), status=400)
110 request(authorize_path(authorize) + '&client_id=other', status=400)
111 request(authorize_path({**authorize, 'scope': 'openid profile unknown'}), status=400)
112 assert request(authorize_path(authorize), session=False, status=302)['location'].startswith('/sign-in?next=')
113 denied = request(authorize_path({**authorize, 'prompt': 'none'}), session=False, status=302)['location']
114 assert urllib.parse.parse_qs(urllib.parse.urlparse(denied).query)['error'] == ['login_required']
115 def code(values=authorize):
116 location = request(authorize_path(values), status=302)['location']
117 result = urllib.parse.parse_qs(urllib.parse.urlparse(location).query)
118 assert result['state'] == ['fixture-state']
119 return result['code'][0]
120 exchange = {'client_id': 'shale', 'client_secret': secret, 'grant_type': 'authorization_code',
121 'redirect_uri': callback, 'code': code(), 'code_verifier': verifier}
122 for change in [{'client_secret': 'wrong'}, {'client_id': 'other'}, {'code_verifier': 'wrong'}, {'redirect_uri': 'https://evil.example/'}]:
123 request('/auth/oidc/token', 'POST', {**exchange, **change}, status=400, form=True)
124 tokens = request('/auth/oidc/token', 'POST', exchange, form=True)
125 request('/auth/oidc/token', 'POST', exchange, status=400, form=True)
126 parts = tokens['id_token'].split('.')
127 key.verify(decode(parts[2]), (parts[0] + '.' + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256())
128 claims = json.loads(decode(parts[1])); assert claims['iss'] == origin and claims['sub'] == actor and claims['aud'] == 'shale'
129 assert claims['nonce'] == 'fixture-nonce' and claims['preferred_username'] == 'clover'
130 assert claims['groups'] == ['role:infra-admin'] and claims['email_verified'] is True
131 assert claims['at_hash'] == b64(hashlib.sha256(tokens['access_token'].encode()).digest()[:16])
132 bearer = lambda token: {'Authorization': 'Bearer ' + token}
133 assert request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']))['sub'] == actor
134 refresh = {'client_id': 'shale', 'client_secret': secret, 'grant_type': 'refresh_token', 'refresh_token': tokens['refresh_token']}
135 rotated = request('/auth/oidc/token', 'POST', refresh, form=True)
136 request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401)
137 request('/auth/oidc/token', 'POST', refresh, form=True, status=400)
138 request('/auth/oidc/userinfo', extra=bearer(rotated['access_token']), status=401)
139 request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': rotated['refresh_token']}, form=True, status=400)
140 # Shale uses a confidential client without PKCE; basic client auth also works.
141 unbound = {k:v for k,v in authorize.items() if not k.startswith('code_challenge')}
142 basic = {'Authorization': 'Basic ' + base64.b64encode(('shale:' + secret).encode()).decode()}
143 tokens = request('/auth/oidc/token', 'POST', {'grant_type': 'authorization_code', 'code': code(unbound), 'redirect_uri': callback}, extra=basic, form=True)
144 request('/auth/oidc/revoke', 'POST', {'token': tokens['refresh_token']}, extra=basic, form=True)
145 request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401)
146 # Reauthentication must use a new session, even when a caller spoofs the continuation.
147 forced = request(authorize_path({**unbound, 'prompt': 'login', 'snow_reauth': '1'}), status=302)['location']
148 continuation = urllib.parse.parse_qs(urllib.parse.urlparse(forced).query)['next'][0]
149 assert request(continuation, status=302)['location'].startswith('/sign-in?next=')
150 request('/auth/password', 'POST', login)
151 resumed = request(continuation, status=302)['location']
152 assert 'code=' in resumed and resumed.startswith(callback)
153 tokens = request('/auth/oidc/token', 'POST', {**exchange, 'code': code()}, form=True)
154 # Account disabling is checked at the token endpoint, not only at sign-in.
155 disabled_code = code()
156 db = sqlite3.connect(data / 'accounts.sqlite')
157 db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id=?", (actor,)); db.commit()
158 request('/auth/oidc/token', 'POST', {**exchange, 'code': disabled_code}, status=400, form=True)
159 request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401)
160 db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('true')) WHERE id=?", (actor,)); db.commit(); db.close()
161 stop(); start()
162 assert request('/auth/oidc/jwks')['keys'][0] == jwk
163 request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']))
164 request('/auth/sign-out', 'POST', {})
165 request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401)
166 request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': tokens['refresh_token']}, form=True, status=400)
167 print(json.dumps({'signature_nonce_alias_claims': 'passed', 'client_redirect_pkce_binding': 'passed',
168 'code_one_use': 'passed', 'refresh_rotation_reuse_revocation': 'passed', 'basic_client_auth': 'passed',
169 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed'}))
170 finally:
171 if server and server.poll() is None: stop()
172 log.close()
173
174
175if __name__ == '__main__':
176 main()
tools/oidc-provider.py created+14
......@@ -0,0 +1,14 @@
1#!/usr/bin/env python3
2"""Provision a first-party OIDC client through the dashboard's host-only CLI."""
3import json
4import subprocess
5import sys
6
7data = json.load(sys.stdin)
8if data["request"]["kind"] != "client":
9 raise ValueError("unsupported Snowglobe input")
10result = subprocess.run(
11 ["podman", "exec", "-i", "studio-dashboard", "/bin/home-dashboard", "--oidc-client"],
12 input=json.dumps(data), text=True, check=True, capture_output=True,
13)
14sys.stdout.write(result.stdout)
tools/studio.py+8-6
......@@ -56,7 +56,7 @@ def service_dir(name):
5656
5757
5858def dependencies(data):
59 return set(data["dependsOn"]) | {item["provider"] for item in data["inputs"].values()}
59 return set(data["dependsOn"]) | {item["provider"] for item in data["inputs"].values() if item["provider"] != "snowglobe"}
6060
6161
6262def ordered(data):
......@@ -680,7 +680,8 @@ def provision_inputs(data, definitions, stage_id=None, postgres_source=None):
680680 for alias, request in data["inputs"].items():
681681 if not NAME.fullmatch(alias):
682682 raise ValueError(f"invalid input alias: {alias}")
683 provider = definitions[request["provider"]]
683 native = request["provider"] == "snowglobe"
684 provider = {"id": "snowglobe", "provide": True, "containers": {}} if native else definitions[request["provider"]]
684685 if not provider.get("provide"):
685686 raise ValueError(f"{provider['id']} does not provide inputs")
686687 path = f"nomad/jobs/{data['id']}/inputs/{alias}"
......@@ -700,7 +701,7 @@ def provision_inputs(data, definitions, stage_id=None, postgres_source=None):
700701 payload["stageId"] = stage_id
701702 if postgres_source and provider["id"] == "postgres":
702703 payload["sourceContainer"] = postgres_source
703 script = config_path(provider["id"], provider["provide"])
704 script = REPO / "tools/oidc-provider.py" if native else config_path(provider["id"], provider["provide"])
704705 result = command("python3", str(script), input=json.dumps(payload), capture=True)
705706 values = json.loads(result.stdout)
706707 if not isinstance(values, dict) or not values or any(
......@@ -781,13 +782,14 @@ def bootstrap(all_data):
781782def destroy_stage(stage, metadata, definitions):
782783 subprocess.run(["nomad", "job", "stop", "-purge", "-yes", stage], check=False)
783784 for alias, request in metadata["inputs"].items():
784 provider = definitions[request["provider"]]
785 native = request["provider"] == "snowglobe"
786 provider = {"id": "snowglobe", "provide": True, "containers": {}} if native else definitions[request["provider"]]
785787 own = (get_variable("nomad/jobs/" + provider["id"]) or {}).get("Items", {})
786788 variable = get_variable(f"nomad/jobs/{stage}/inputs/{alias}")
787789 existing = variable["Items"] if variable else None
788790 hosts = [task["http"]["hostname"] for task in provider["containers"].values()
789791 if task.get("http") and task["http"].get("hostname")]
790 script = config_path(provider["id"], provider["provide"])
792 script = REPO / "tools/oidc-provider.py" if native else config_path(provider["id"], provider["provide"])
791793 command("python3", str(script), input=json.dumps({
792794 "operation": "delete", "request": request, "stageId": stage,
793795 "providerSecrets": own, "host": hosts[0] if hosts else None,
......@@ -963,7 +965,7 @@ def main():
963965 parser.error("destroy requires a stage ID")
964966 token()
965967 metadata = json.loads((STATE / "stages" / f"{args.name}.json").read_text())
966 names = {metadata["sourceId"]} | {request["provider"] for request in metadata["inputs"].values()}
968 names = {metadata["sourceId"]} | {request["provider"] for request in metadata["inputs"].values() if request["provider"] != "snowglobe"}
967969 definitions = {name: load(name, properties) for name in names}
968970 destroy_stage(args.name, metadata, definitions)
969971 return