Skip to main content

mz/command/
profile.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Implementation of the `mz profile` command.
17//!
18//! Consult the user-facing documentation for details.
19
20use 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
47/// Logs in through the admin endpoint's OAuth authorization server in the
48/// default web browser, then creates an app password with the resulting access
49/// token.
50///
51/// This is the authorization code flow for native apps (RFC 8252) with PKCE
52/// (RFC 7636). The browser only carries a single-use authorization code, which
53/// cannot be redeemed without `code_verifier`, and `code_verifier` never leaves
54/// this process. The app password is created over a direct HTTPS request.
55///
56/// Without `client_id`, a client is registered for this login through dynamic
57/// client registration (RFC 7591), with this login's exact redirect URI.
58pub 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    // RFC 8252 ยง7.3: use the loopback IP literal, not `localhost`, so the
79    // redirect cannot be resolved to some other interface.
80    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
154/// Registers a public OAuth client that may only redirect to `redirect_uri`,
155/// and returns its client ID.
156async 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
193/// Prompts the user for the profile email and passowrd in Materialize.
194/// Notice that the password is the same as the user uses to log into
195/// the console, and not the app-password.
196pub async fn init_without_browser(admin_endpoint: Option<Url>) -> Result<AppPassword, Error> {
197    // Handle interactive user input
198    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    // Trim lines
205    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    // Build client
217    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
239/// Initiates the profile creation process.
240///
241/// There are only two ways to create a profile:
242/// 1. By prompting your user and email.
243/// 2. By opening the browser and creating the credentials in the console.
244pub 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    // Fail before logging in, so that an unwritable configuration file doesn't
264    // leave behind an app password that no profile records.
265    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
290/// List all the possible config values for the profile.
291pub fn list(cx: &Context) -> Result<(), Error> {
292    if let Some(profiles) = cx.config_file().profiles() {
293        let output = cx.output_formatter();
294
295        // Output formatting structure.
296        #[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
307/// Removes the profile from the configuration file.
308pub 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
317/// Represents the args to retrieve a profile configuration value.
318pub struct ConfigGetArgs<'a> {
319    /// Represents the configuration field name to retrieve the value.
320    pub name: &'a str,
321}
322
323/// Represents the possible fields in a profile configuration.
324#[derive(Clone, Debug)]
325pub enum ConfigArg {
326    /// Represents `[TomlProfile::admin_endpoint]`
327    AdminAPI,
328    /// Represents `[TomlProfile::app_password]`
329    AppPassword,
330    /// Represents `[TomlProfile::cloud_endpoint]`
331    CloudAPI,
332    /// Represents `[TomlProfile::region]`
333    Region,
334    /// Represents `[TomlProfile::vault]`
335    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
365/// Shows the value of a profile configuration field.
366pub 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
376/// Shows all the possible field and its values in the profile configuration.
377pub 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    // Structure to format the output. The name of the field equals the column name.
382    #[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
397/// Represents the args to set the value of a profile configuration field.
398pub struct ConfigSetArgs<'a> {
399    /// Represents the name of the field to set the value.
400    pub name: &'a str,
401    /// Represents the new value of the field.
402    pub value: &'a str,
403}
404
405/// Sets a value in the profile configuration.
406pub 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
415/// Represents the args to remove the value from a profile configuration field.
416pub struct ConfigRemoveArgs<'a> {
417    /// Represents the name of the field to remove.
418    pub name: &'a str,
419}
420
421/// Removes the value from a profile configuration field.
422pub 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}