Skip to content

Commit

Permalink
Adds limited support for MFA with one time passwords (#78)
Browse files Browse the repository at this point in the history
* add code to get OTPs when creating tokens

* adds a flag to avoid interactive prompts

* ensure "noninteractive" works everywhere

* remove typo

* change how just is installed for CI
  • Loading branch information
TimSimpson authored Nov 29, 2023
1 parent 1aaa922 commit ba750f5
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 39 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
- name: checkout
uses: actions/checkout@v1
- name: install Just
run: cargo install just --version 1.2.0
uses: extractions/setup-just@v1
with:
just-version: 1.14.0
- name: Build and Test
run: just ci
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ check:
clean:
rm -r target

# builds nexus
# builds esc
build:
cargo build --locked

Expand Down
156 changes: 151 additions & 5 deletions base/src/identity/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ where
})
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct MfaRequiredError {
error: String,
mfa_token: Option<String>,
}

pub type OtpPrompt = fn() -> std::result::Result<String, String>;

pub async fn create(
client: &reqwest::Client,
config: &TokenConfig,
user_name: &str,
password: &str,
otp_prompt: Option<OtpPrompt>,
) -> Result<Token> {
let mut form = std::collections::HashMap::new();

Expand All @@ -39,13 +48,152 @@ pub async fn create(
let url = format!("{}/oauth/token", &config.identity_url);
let req = client.post(url.as_str()).form(&form);

parse_result(req.send().await?).await
let resp = req.send().await?;

handle_initial_oauth_token_resp(client, config, otp_prompt, resp).await
}

// interprets the response from oauth/token. May take other actions if needed,
// such as handling an MFA challenge
async fn handle_initial_oauth_token_resp(
client: &reqwest::Client,
config: &TokenConfig,
otp_prompt: Option<OtpPrompt>,
resp: reqwest::Response,
) -> Result<Token> {
if resp.status().is_success() {
let result: Token = resp.json().await?;
Ok(result)
} else {
let mfa_token = get_mfa_token_or_error(resp).await?;
challenge_mfa_and_confirm_otp(client, config, &mfa_token).await?;
match otp_prompt {
Some(prompt_for_otp) => {
let result = prompt_for_otp();
match result {
Ok(otp) => {
create_with_otp(client, config, mfa_token, otp).await
},
Err(err) => {
Err(IdentityError {
message: format!("Error reading one time password: {}", err),
status_code: None,
})
}
}
}
None => {
Err(IdentityError {
message: "This account has MFA enabled but the ability for this client to interactively prompt for a one time password was not enabled for this call.".to_string(),
status_code: None,
})
}
}
}
}

async fn get_mfa_token_or_error(resp: reqwest::Response) -> Result<String> {
let status = resp.status();
if status == 403 {
let result: std::result::Result<MfaRequiredError, reqwest::Error> = resp.json().await;
match result {
Ok(error) => {
if error.error == "mfa_required" {
match error.mfa_token {
None => {
Err(IdentityError {
message: "Identity returned a 403 with an mfa_required error code, but no token.".to_string(),
status_code: Some(status),
})
}
Some(mfa_token) => Ok(mfa_token),
}
} else {
Err(IdentityError {
message: "not authorized".to_string(),
status_code: Some(status),
})
}
}
Err(err) => {
Err(IdentityError {
message: format!("Identity returned a 403 which could not be converted into a known error format: {}", err),
status_code: Some(status),
})
}
}
} else {
Err(IdentityError {
message: "not authorized".to_string(),
status_code: Some(status),
})
}
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct MfaChallengeArgs {
mfa_token: String,
challenge_type: String,
client_id: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct MfaChallengeResp {
challenge_type: String,
}

async fn challenge_mfa_and_confirm_otp(
client: &reqwest::Client,
config: &TokenConfig,
mfa_token: &str,
) -> Result<()> {
let args = MfaChallengeArgs {
challenge_type: "otp".to_string(),
client_id: config.client_id.clone(),
mfa_token: mfa_token.to_string(),
};

let url = format!("{}/mfa/challenge", &config.identity_url);
let req = client.post(url.as_str()).json(&args);

let resp = req.send().await?;
let resp: MfaChallengeResp = parse_result(resp).await?;
if resp.challenge_type == "otp" {
Ok(())
} else {
Err(IdentityError {
message: "Challenge type for this user's MFA was not OTP.".to_string(),
status_code: None,
})
}
}

pub async fn create_with_otp(
client: &reqwest::Client,
config: &TokenConfig,
mfa_token: String,
otp: String,
) -> Result<Token> {
let mut form = std::collections::HashMap::new();

form.insert("client_id", config.client_id.as_ref());
form.insert("grant_type", "http://auth0.com/oauth/grant-type/mfa-otp");
form.insert("mfa_token", mfa_token.as_ref());
form.insert("otp", otp.as_ref());

let url = format!("{}/oauth/token", &config.identity_url);
let req = client.post(url.as_str()).form(&form);

let resp = req.send().await?;

parse_result(resp).await
}

pub async fn refresh(
client: &reqwest::Client,
config: &TokenConfig,
refresh_token: &str,
otp_prompt: Option<OtpPrompt>,
) -> Result<Token> {
let url = format!("{}/oauth/token", &config.identity_url);
let mut form = std::collections::HashMap::new();
Expand All @@ -58,9 +206,7 @@ pub async fn refresh(

debug!("Token refresh on : {:?}", req);

let token: Token = parse_result(req.send().await?).await?;

debug!("Token expires_in: {}", token.expires_in);
let resp = req.send().await?;

Ok(token)
handle_initial_oauth_token_resp(client, config, otp_prompt, resp).await
}
55 changes: 41 additions & 14 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ pub struct Opt {
)]
refresh_token: Option<String>,

#[structopt(
long,
help = "If true never prompt for authentication details",
global = true
)]
noninteractive: bool,

#[structopt(subcommand)]
cmd: Command,
}
Expand Down Expand Up @@ -1416,21 +1423,28 @@ impl esc_api::Authorization for StaticAuthorization {
async fn get_token(
token_config: esc_api::TokenConfig,
refresh_token: Option<String>,
noninteractive: bool,
) -> Result<esc_api::Token, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
match refresh_token {
Some(refresh_token) => {
let otp_prompt: Option<esc_client_base::identity::operations::OtpPrompt> =
match noninteractive {
true => None,
false => Some(esc_client_store::prompt_for_otp),
};
let refreshed_token = esc_client_base::identity::operations::refresh(
&client,
&token_config,
&refresh_token,
otp_prompt,
)
.await?;
Ok(refreshed_token)
}
None => {
let mut store = esc_client_store::token_store(token_config).await?;
let token = store.access(&client).await?;
let token = store.access(&client, noninteractive).await?;
Ok(token)
}
}
Expand Down Expand Up @@ -1465,11 +1479,12 @@ struct ClientBuilder {
observer: Option<Arc<dyn esc_api::RequestObserver + Send + Sync>>,
refresh_token: Option<String>,
token_config: esc_api::TokenConfig,
noninteractive: bool,
}

impl ClientBuilder {
pub async fn create(self) -> Result<esc_api::Client, Box<dyn std::error::Error>> {
let token = get_token(self.token_config, self.refresh_token).await?;
let token = get_token(self.token_config, self.refresh_token, self.noninteractive).await?;
let authorization = StaticAuthorization {
authorization_header: token.authorization_header(),
};
Expand Down Expand Up @@ -1554,6 +1569,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
observer,
refresh_token: opt.refresh_token.clone(),
token_config: token_config.clone(),
noninteractive: opt.noninteractive,
};

let silence_errors = !opt.output_format.is_v1();
Expand Down Expand Up @@ -1676,18 +1692,28 @@ async fn call_api<'a, 'b>(
let client = reqwest::Client::new();
let mut store = esc_client_store::token_store(token_config).await?;

let token = match params.email {
Some(email) => match params.unsafe_password {
Some(password) => store.create_token(&client, email, password).await,
None => {
store
.create_token_from_prompt_password_only(&client, email)
.await
}
},
None => store.create_token_from_prompt(&client).await,
}?;
println!("{}", token.refresh_token().unwrap().as_str());
match client_builder.noninteractive {
true => {
println!("--noninteractive mode set, cannot prompt for password");
std::process::exit(-1)
}
false => {
let token = match params.email {
Some(email) => match params.unsafe_password {
Some(password) => {
store.create_token(&client, email, password).await
}
None => {
store
.create_token_from_prompt_password_only(&client, email)
.await
}
},
None => store.create_token_from_prompt(&client).await,
}?;
println!("{}", token.refresh_token().unwrap().as_str());
}
}
}
TokensCommand::Display(_params) => {
let store = esc_client_store::token_store(token_config).await?;
Expand All @@ -1697,6 +1723,7 @@ async fn call_api<'a, 'b>(
println!("{}", token.refresh_token().unwrap());
} else {
println!("No active refresh token");
std::process::exit(-1)
}
}
},
Expand Down
1 change: 1 addition & 0 deletions store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod typical;

pub use config::Settings;
pub use errors::StoreError;
pub use store::prompt_for_otp;
pub use store::TokenStore;
pub use store::TokenValidator;

Expand Down
1 change: 1 addition & 0 deletions store/src/store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ pub mod token_file;
pub mod token_store;
pub mod token_validator;

pub use token_store::prompt_for_otp;
pub use token_store::TokenStore;
pub use token_validator::TokenValidator;
Loading

0 comments on commit ba750f5

Please sign in to comment.