1use std::{io::Write, str::FromStr};
21
22use base64::Engine;
23use base64::engine::general_purpose::URL_SAFE_NO_PAD;
24use mz_frontegg_auth::AppPassword;
25use mz_frontegg_client::client::app_password::CreateAppPasswordRequest;
26use mz_frontegg_client::client::{Authentication, Client as AdminClient, Credentials};
27use mz_frontegg_client::config::{
28 ClientBuilder as AdminClientBuilder, ClientConfig as AdminClientConfig,
29 DEFAULT_ENDPOINT as DEFAULT_ADMIN_ENDPOINT,
30};
31use sha2::{Digest, Sha256};
32
33use serde::{Deserialize, Serialize};
34use tabled::Tabled;
35use tokio::{select, sync::mpsc};
36use url::Url;
37
38use crate::ui::OptionalStr;
39use crate::{
40 config_file::TomlProfile,
41 context::{Context, ProfileContext},
42 error::Error,
43 error::Error::ProfileNameAlreadyExistsError,
44 server::oauth_callback_server,
45};
46
47pub async fn init_with_oauth(
59 admin_endpoint: Option<Url>,
60 client_id: Option<&str>,
61) -> Result<AppPassword, Error> {
62 let admin_endpoint = admin_endpoint.unwrap_or_else(|| DEFAULT_ADMIN_ENDPOINT.clone());
63 let endpoint_url = |path: [&str; 2]| {
64 let mut url = admin_endpoint.clone();
65 url.path_segments_mut()
66 .expect("constructor validated URL can be a base")
67 .clear()
68 .extend(path);
69 url
70 };
71
72 let code_verifier = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>());
73 let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
74 let state = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>());
75
76 let (tx, mut rx) = mpsc::unbounded_channel();
77 let (server, port) = oauth_callback_server(state.clone(), tx).await;
78 let redirect_uri = format!("http://127.0.0.1:{port}/");
81
82 let http = reqwest::Client::new();
83 let client_id = match client_id {
84 Some(client_id) => client_id.to_string(),
85 None => register_oauth_client(&http, endpoint_url(["oauth", "dcr"]), &redirect_uri).await?,
86 };
87 let client_id = client_id.as_str();
88
89 let mut authorize_url = endpoint_url(["oauth", "authorize"]);
90 authorize_url
91 .query_pairs_mut()
92 .append_pair("response_type", "code")
93 .append_pair("client_id", client_id)
94 .append_pair("redirect_uri", &redirect_uri)
95 .append_pair("scope", "openid")
96 .append_pair("state", &state)
97 .append_pair("code_challenge", &code_challenge)
98 .append_pair("code_challenge_method", "S256");
99
100 if let Err(_err) = open::that(authorize_url.as_str()) {
101 println!(
102 "Error: Unable to launch a web browser. Access the login page using this link: '{}', or execute `mz profile init --no-browser` in your command line.",
103 authorize_url
104 )
105 }
106
107 let code = select! {
108 _ = server => unreachable!("server should not shut down"),
109 result = rx.recv() => match result {
110 Some(code) => code?,
111 None => panic!("failed to login via browser"),
112 },
113 };
114
115 #[derive(Deserialize)]
116 struct TokenResponse {
117 access_token: String,
118 }
119
120 let res = http
121 .post(endpoint_url(["oauth", "token"]))
122 .form(&[
123 ("grant_type", "authorization_code"),
124 ("code", code.as_str()),
125 ("redirect_uri", redirect_uri.as_str()),
126 ("client_id", client_id),
127 ("code_verifier", code_verifier.as_str()),
128 ])
129 .send()
130 .await?;
131 let status = res.status();
132 if !status.is_success() {
133 let body = res.text().await.unwrap_or_default();
134 return Err(Error::OAuthError(format!(
135 "token endpoint returned {status}: {body}"
136 )));
137 }
138 let TokenResponse { access_token } = res.json().await?;
139
140 let admin_client = AdminClientBuilder::default()
141 .endpoint(admin_endpoint)
142 .build(AdminClientConfig {
143 authentication: Authentication::AccessToken(access_token),
144 });
145 let app_password = admin_client
146 .create_app_password(CreateAppPasswordRequest {
147 description: "Materialize CLI (mz)",
148 })
149 .await?;
150
151 Ok(app_password)
152}
153
154async fn register_oauth_client(
157 http: &reqwest::Client,
158 mut dcr_url: Url,
159 redirect_uri: &str,
160) -> Result<String, Error> {
161 dcr_url
162 .path_segments_mut()
163 .expect("constructor validated URL can be a base")
164 .push("register");
165
166 #[derive(Deserialize)]
167 struct RegistrationResponse {
168 client_id: String,
169 }
170
171 let res = http
172 .post(dcr_url)
173 .json(&serde_json::json!({
174 "client_name": "Materialize CLI (mz)",
175 "redirect_uris": [redirect_uri],
176 "grant_types": ["authorization_code"],
177 "response_types": ["code"],
178 "token_endpoint_auth_method": "none",
179 }))
180 .send()
181 .await?;
182 let status = res.status();
183 if !status.is_success() {
184 let body = res.text().await.unwrap_or_default();
185 return Err(Error::OAuthError(format!(
186 "client registration returned {status}: {body}"
187 )));
188 }
189 let RegistrationResponse { client_id } = res.json().await?;
190 Ok(client_id)
191}
192
193pub async fn init_without_browser(admin_endpoint: Option<Url>) -> Result<AppPassword, Error> {
197 let mut email = String::new();
199
200 print!("Email: ");
201 let _ = std::io::stdout().flush();
202 std::io::stdin().read_line(&mut email).unwrap();
203
204 if email.ends_with('\n') {
206 email.pop();
207 if email.ends_with('\r') {
208 email.pop();
209 }
210 }
211
212 print!("Password: ");
213 let _ = std::io::stdout().flush();
214 let password = rpassword::read_password().unwrap();
215
216 let mut admin_client_builder = AdminClientBuilder::default();
218
219 if let Some(admin_endpoint) = admin_endpoint {
220 admin_client_builder = admin_client_builder.endpoint(admin_endpoint);
221 }
222
223 let admin_client: AdminClient = admin_client_builder.build(AdminClientConfig {
224 authentication: mz_frontegg_client::client::Authentication::Credentials(Credentials {
225 email,
226 password,
227 }),
228 });
229
230 let app_password = admin_client
231 .create_app_password(CreateAppPasswordRequest {
232 description: "Materialize CLI (mz)",
233 })
234 .await?;
235
236 Ok(app_password)
237}
238
239pub async fn init(
245 scx: &Context,
246 no_browser: bool,
247 oauth_client_id: Option<&str>,
248 force: bool,
249 admin_endpoint: Option<Url>,
250 cloud_endpoint: Option<Url>,
251) -> Result<(), Error> {
252 let config_file = scx.config_file();
253 let profile = scx
254 .get_global_profile()
255 .unwrap_or_else(|| config_file.profile().to_string());
256
257 if let Some(profiles) = scx.config_file().profiles() {
258 if profiles.contains_key(&profile) && !force {
259 return Err(ProfileNameAlreadyExistsError(profile));
260 }
261 }
262
263 config_file.ensure_writable().await?;
266
267 let app_password = if no_browser {
268 init_without_browser(admin_endpoint.clone()).await?
269 } else {
270 let oauth_endpoint = scx.get_admin_endpoint(
271 cloud_endpoint.clone(),
272 admin_endpoint.as_ref().map(Url::as_str),
273 )?;
274 init_with_oauth(oauth_endpoint, oauth_client_id).await?
275 };
276
277 let new_profile = TomlProfile {
278 app_password: Some(app_password.to_string()),
279 vault: None,
280 region: None,
281 admin_endpoint: admin_endpoint.map(|url| url.to_string()),
282 cloud_endpoint: cloud_endpoint.map(|url| url.to_string()),
283 };
284
285 config_file.add_profile(profile, new_profile).await?;
286
287 Ok(())
288}
289
290pub fn list(cx: &Context) -> Result<(), Error> {
292 if let Some(profiles) = cx.config_file().profiles() {
293 let output = cx.output_formatter();
294
295 #[derive(Clone, Serialize, Deserialize, Tabled)]
297 struct ProfileName<'a> {
298 #[tabled(rename = "Name")]
299 name: &'a str,
300 }
301 output.output_table(profiles.keys().map(|name| ProfileName { name }))?;
302 }
303
304 Ok(())
305}
306
307pub async fn remove(cx: &Context) -> Result<(), Error> {
309 cx.config_file()
310 .remove_profile(
311 &cx.get_global_profile()
312 .unwrap_or_else(|| cx.config_file().profile().to_string()),
313 )
314 .await
315}
316
317pub struct ConfigGetArgs<'a> {
319 pub name: &'a str,
321}
322
323#[derive(Clone, Debug)]
325pub enum ConfigArg {
326 AdminAPI,
328 AppPassword,
330 CloudAPI,
332 Region,
334 Vault,
336}
337
338impl FromStr for ConfigArg {
339 type Err = String;
340
341 fn from_str(s: &str) -> Result<Self, Self::Err> {
342 match s.to_lowercase().as_str() {
343 "admin-api" => Ok(ConfigArg::AdminAPI),
344 "app-password" => Ok(ConfigArg::AppPassword),
345 "cloud-api" => Ok(ConfigArg::CloudAPI),
346 "region" => Ok(ConfigArg::Region),
347 "vault" => Ok(ConfigArg::Vault),
348 _ => Err("Invalid profile configuration parameter.".to_string()),
349 }
350 }
351}
352
353impl ToString for ConfigArg {
354 fn to_string(&self) -> String {
355 match self {
356 ConfigArg::AdminAPI => "admin-api".to_string(),
357 ConfigArg::AppPassword => "app-password".to_string(),
358 ConfigArg::CloudAPI => "cloud-api".to_string(),
359 ConfigArg::Region => "region".to_string(),
360 ConfigArg::Vault => "vault".to_string(),
361 }
362 }
363}
364
365pub fn config_get(
367 cx: &ProfileContext,
368 ConfigGetArgs { name }: ConfigGetArgs<'_>,
369) -> Result<(), Error> {
370 let profile = cx.get_profile();
371 let value = cx.config_file().get_profile_param(name, &profile)?;
372 cx.output_formatter().output_scalar(value)?;
373 Ok(())
374}
375
376pub fn config_list(cx: &ProfileContext) -> Result<(), Error> {
378 let profile_params = cx.config_file().list_profile_params(&cx.get_profile())?;
379 let output = cx.output_formatter();
380
381 #[derive(Serialize, Deserialize, Tabled)]
383 struct ProfileParam<'a> {
384 #[tabled(rename = "Name")]
385 name: &'a str,
386 #[tabled(rename = "Value")]
387 value: OptionalStr<'a>,
388 }
389
390 output.output_table(profile_params.iter().map(|(name, value)| ProfileParam {
391 name,
392 value: OptionalStr(value.as_deref()),
393 }))?;
394 Ok(())
395}
396
397pub struct ConfigSetArgs<'a> {
399 pub name: &'a str,
401 pub value: &'a str,
403}
404
405pub async fn config_set(
407 cx: &ProfileContext,
408 ConfigSetArgs { name, value }: ConfigSetArgs<'_>,
409) -> Result<(), Error> {
410 cx.config_file()
411 .set_profile_param(&cx.get_profile(), name, Some(value))
412 .await
413}
414
415pub struct ConfigRemoveArgs<'a> {
417 pub name: &'a str,
419}
420
421pub async fn config_remove(
423 cx: &ProfileContext,
424 ConfigRemoveArgs { name }: ConfigRemoveArgs<'_>,
425) -> Result<(), Error> {
426 cx.config_file()
427 .set_profile_param(&cx.get_profile(), name, None)
428 .await
429}