Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth: prompt the user to open the browser #418

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 85 additions & 8 deletions cli/src/cmd_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

// Copyright 2024 Oxide Computer Company

use std::{collections::HashMap, fs::File, io::Read};
use std::{
collections::HashMap,
fs::File,
io::{stdin, stdout, BufRead, Read, Write},
};

use anyhow::{anyhow, bail, Result};
use async_trait::async_trait;
Expand Down Expand Up @@ -243,21 +247,36 @@ impl CmdAuthLogin {

let uri = details.verification_uri().to_string();

println!(
"Copy your one-time code:\n {}",
details.user_code().secret()
);

let opened = match (&self.browser, self.no_browser) {
(None, false) => open::that(&uri).is_ok(),
(Some(app), false) => open::with(&uri, app).is_ok(),
(None, false) => {
proceed(
&format!("Press ENTER to open {} in your browser...", &uri),
&mut stdout(),
&mut stdin().lock(),
)?;
open::that(&uri).is_ok()
}
(Some(app), false) => {
proceed(
&format!("Press ENTER to open {} in your browser...", &uri),
&mut stdout(),
&mut stdin().lock(),
)?;
open::with(&uri, app).is_ok()
}
(None, true) => false,
(Some(_), true) => unreachable!(),
};

if opened {
println!("Opened this URL in your browser:\n {}", uri);
} else {
if !opened {
println!("Open this URL in your browser:\n {}", uri);
}

println!("\nEnter the code: {}\n", details.user_code().secret());

auth_client
.exchange_device_access_token(&details)
.request_async(async_http_client_custom, tokio::time::sleep, None)
Expand Down Expand Up @@ -329,6 +348,14 @@ impl CmdAuthLogin {
}
}

/// Write the prompt to the given writer, then read a single line from the given reader. Used to
/// wait for the user to press ENTER before proceeding.
fn proceed<W: Write, R: BufRead>(prompt: &str, writer: &mut W, reader: &mut R) -> Result<usize> {
write!(writer, "{}", prompt)?;
writer.flush()?;
Ok(reader.read_line(&mut String::new())?)
}

/// Removes saved authentication information.
///
/// This command does not invalidate any tokens from the hosts.
Expand Down Expand Up @@ -638,6 +665,10 @@ mod tests {
// }
// }

use std::io::{BufReader, BufWriter};

use crate::cmd_auth::proceed;

#[test]
fn test_parse_host() {
use super::parse_host;
Expand Down Expand Up @@ -699,6 +730,52 @@ mod tests {
Ok(host) if host == "http://example.com:8888/"
));
}

#[test]
fn test_proceed() {
struct TestCase<'a> {
prompt: &'a str,
input: &'a [u8],
}

let test_cases = vec![
TestCase {
prompt: "",
input: b"",
},
TestCase {
prompt: "Prompt: ",
input: b"\n",
},
TestCase {
prompt: "Prompt: ",
input: b"foo\n",
},
];

// find_subsequence is a helper function to determine whether needle is within haystack.
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}

for test_case in test_cases {
let mut writer = BufWriter::new(Vec::new());
let mut reader = BufReader::new(test_case.input);

// Assert that proceed succeeded and read a line of input from the reader.
assert!(proceed(test_case.prompt, &mut writer, &mut reader).is_ok());

// Assert that the prompt, if passed, is written to the writer.
if !test_case.prompt.is_empty() {
assert!(
find_subsequence(writer.get_ref().as_slice(), test_case.prompt.as_bytes())
.is_some()
);
}
}
}
}

#[test]
Expand Down
161 changes: 161 additions & 0 deletions cli/tests/test_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

// Copyright 2023 Oxide Computer Company

use assert_cmd::Command;
use httpmock::MockServer;
use oxide_httpmock::MockServerExt;
use rand::SeedableRng;
use serde_json::json;

#[test]
fn test_auth_login_browser() {
let mut _src = rand::rngs::SmallRng::seed_from_u64(42);
let server = MockServer::start();

let device_auth_request_mock = server.device_auth_request(|when, then| {
when.into_inner().any_request();
then.default_response(
200,
json!({
"device_code": "foodevicecode",
"user_code": "foousercode",
"verification_uri": server.url("/verify"),
"expires_in": 10000
}),
);
});

let device_access_token_mock = server.device_access_token(|when, then| {
when.into_inner().any_request();
then.default_response(
200,
json!({
"access_token": "footoken",
"token_type": "bearer",
"expires_in": 10000
}),
);
});

// TODO: Use server.current_user_view(config_fn) instead. It was not working last it was tried.
let current_user_view_mock = server.mock(|when, then| {
when.path_contains("v1/me");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"display_name": "foo",
"id": "bf8f5c05-2aa6-47f2-9f8a-92896e1fe175",
"silo_id": "aa09e636-e452-466d-a3b3-45e24c49da61",
"silo_name": "foo-silo"
}));
});

Command::cargo_bin("oxide")
.unwrap()
.env("RUST_BACKTRACE", "1")
.arg("auth")
.arg("login")
.arg("--host")
.arg(server.url(""))
.write_stdin("y")
.assert()
.success()
.stdout(format!(
r#"Copy your one-time code:
foousercode
Press ENTER to open {} in your browser...CurrentUser {{
display_name: "foo",
id: bf8f5c05-2aa6-47f2-9f8a-92896e1fe175,
silo_id: aa09e636-e452-466d-a3b3-45e24c49da61,
silo_name: Name(
"foo-silo",
),
}}
Logged in as bf8f5c05-2aa6-47f2-9f8a-92896e1fe175
"#,
server.url("/verify")
));

device_auth_request_mock.assert();
device_access_token_mock.assert();
current_user_view_mock.assert();
}

#[test]
fn test_auth_login_no_browser() {
let mut _src = rand::rngs::SmallRng::seed_from_u64(42);
let server = MockServer::start();

let device_auth_request_mock = server.device_auth_request(|when, then| {
when.into_inner().any_request();
then.default_response(
200,
json!({
"device_code": "foodevicecode",
"user_code": "foousercode",
"verification_uri": server.url("/verify"),
"expires_in": 10000
}),
);
});

let device_access_token_mock = server.device_access_token(|when, then| {
when.into_inner().any_request();
then.default_response(
200,
json!({
"access_token": "footoken",
"token_type": "bearer",
"expires_in": 10000
}),
);
});

// TODO: Use server.current_user_view(config_fn) instead. It was not working last it was tried.
let current_user_view_mock = server.mock(|when, then| {
when.path_contains("v1/me");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"display_name": "foo",
"id": "bf8f5c05-2aa6-47f2-9f8a-92896e1fe175",
"silo_id": "aa09e636-e452-466d-a3b3-45e24c49da61",
"silo_name": "foo-silo"
}));
});

Command::cargo_bin("oxide")
.unwrap()
.env("RUST_BACKTRACE", "1")
.arg("auth")
.arg("login")
.arg("--no-browser")
.arg("--host")
.arg(server.url(""))
.assert()
.success()
.stdout(format!(
r#"Copy your one-time code:
foousercode
Open this URL in your browser:
{}
CurrentUser {{
display_name: "foo",
id: bf8f5c05-2aa6-47f2-9f8a-92896e1fe175,
silo_id: aa09e636-e452-466d-a3b3-45e24c49da61,
silo_name: Name(
"foo-silo",
),
}}
Logged in as bf8f5c05-2aa6-47f2-9f8a-92896e1fe175
"#,
server.url("/verify")
));

device_auth_request_mock.assert();
device_access_token_mock.assert();
current_user_view_mock.assert();
}