himmelblau-idm / himmelblau

Azure Entra ID Authentication, with PAM and NSS modules.
GNU General Public License v3.0
33 stars 6 forks source link

lihimmelblau - MFA login minimal example #178

Open run-stop opened 3 days ago

run-stop commented 3 days ago

Hello everyone,

I'm not sure if this is the right place to ask about libhimmelblau, so I apologize in advance.

I'm trying to create a minimal working example using libhimmelblau to obtain a bearer token for a user in a tenant that enforces MFA authentication. My goal is to develop a CLI application that prompts the user to enter the code in the Microsoft Authenticator App without requiring the use of a web browser.

So far, I have:

  1. Created a PublicClientApplication.
  2. Attempted to use initiate_acquire_token_by_mfa_flow on the app with the appropriate username, password, scope, and resource.

This is failing at the second step, specifically at the handle_auth_config_req_internal call.

I'm unsure if this functionality is unsupported by the library, if there is an issue with my resource string, or if the Azure portal returns something that the parsing function does not understand.

I would appreciate any help you can provide.

dmulder commented 3 days ago

What you're asking should work. Would you share your code and I can help?

dmulder commented 3 days ago

Here's a simple working example:

use himmelblau::{PublicClientApplication, BROKER_APP_ID};
use himmelblau::error::MsalError;
use rpassword::read_password;
use std::io;
use std::io::Write;
use std::thread::sleep;
use std::time::Duration;

#[tokio::main]
async fn main() {
    let mut username = String::new();
    print!("Please enter your EntraID username: ");
    io::stdout().flush().unwrap();
    io::stdin()
        .read_line(&mut username)
        .expect("Failed to read username");
    username = username.trim().to_string();

    let app = PublicClientApplication::new(BROKER_APP_ID, None).expect("Failed creating app");

    print!("{} password: ", &username);
    io::stdout().flush().unwrap();
    let password = match read_password() {
        Ok(password) => password,
        Err(e) => {
            println!("{:?}", e);
            return ();
        }
    };

    let mut mfa_req = match app
        .initiate_acquire_token_by_mfa_flow(&username, &password, vec![], None)
        .await
    {
        Ok(mfa) => mfa,
        Err(e) => {
            println!("{:?}", e);
            return ();
        }
    };
    println!("{}", mfa_req.msg);

    let _token = match mfa_req.mfa_method.as_str() {
        "PhoneAppOTP" | "OneWaySMS" | "ConsolidatedTelephony" => {
            let input = match read_password() {
                Ok(password) => password,
                Err(e) => {
                    println!("{:?} ", e);
                    return ();
                }
            };
            match app
                .acquire_token_by_mfa_flow(&username, Some(&input), None, &mut mfa_req)
                .await
            {
                Ok(token) => token,
                Err(e) => {
                    println!("MFA FAIL: {:?}", e);
                    return ();
                }
            }
        }
        _ => {
            let mut poll_attempt = 1;
            let polling_interval = mfa_req.polling_interval.unwrap_or(5000);
            loop {
                match app
                    .acquire_token_by_mfa_flow(&username, None, Some(poll_attempt), &mut mfa_req)
                    .await
                {
                    Ok(token) => break token,
                    Err(e) => match e {
                        MsalError::MFAPollContinue => {
                            poll_attempt += 1;
                            sleep(Duration::from_millis(polling_interval.into()));
                            continue;
                        }
                        e => {
                            println!("MFA FAIL: {:?}", e);
                            return ();
                        }
                    },
                }
            }
        }
    };
}

FYI, I noticed my example I posted uses the print macro instead of println for the mfa_req.msg, which would cause the message not to flush to stdout. Perhaps this was an issue you were encountering.

run-stop commented 3 days ago

Hello David,

First, thank you for the fast reply!

Running your code fails with exact same message:

GeneralFailure("No MFA methods found")

My code (not as clean as yours):

use himmelblau::error::MsalError::AcquireTokenFailed;
use himmelblau::PublicClientApplication;
use lazy_static::lazy_static; 

lazy_static! {
    static ref TENNANT_ID: &'static str = "xxx";
    static ref CLIENT_ID: &'static str = "yyy";
}

#[tokio::main]
async fn main() {
    let _authority = format!("https://login.microsoftonline.com/{}", *TENNANT_ID);
    println!("TENANT: {}", *TENNANT_ID);
    println!("CLIENT: {}", *CLIENT_ID);
    println!("Authority: {}", _authority);

    let scopes = vec!["User.Read", "Mail.Read", "Maril.ReadAll"];

    let app =
        PublicClientApplication::new(*CLIENT_ID, Some(&_authority)).expect("Failed creating app");

    let username = std::env::var("AZURE_USER").expect("missing AZURE_USER");
    let password = std::env::var("AZURE_PASS").expect("missing AZURE_PASS");

    let mut _token = app
        .acquire_token_by_username_password(&username, &password, scopes.clone())
        .await;
    match &_token {
        Ok(token) => {
            dbg!(&token.access_token);
            dbg!(&token.expires_in);
            dbg!(&token.scope);
            dbg!(&token.token_type);
        }
        Err(e) => match e {
            AcquireTokenFailed(response) => {
                if &response.error == "invalid_grant" {
                    dbg!("MFA Autentication");
                    // It fails here...
                    // Error message is: GeneralFailure("Auth config was not found")
                    let _flow = app
                        .initiate_acquire_token_by_mfa_flow(
                            &username,
                            &password,
                            scopes.clone(),
                            None,
                        )
                        .await
                        .expect("Error initiating MFA flow");
                }
            }
            _ => {
                dbg!(e);
            }
        },
    }
}

In both cases error message originates in the line running the initiate_acquire_token_by_mfa_flow.

Could this be caused by Azure tenant configuration?

Regards, Aleksandar M.

dmulder commented 3 days ago

Hello David,

First, thank you for the fast reply!

Running your code fails with exact same message:

GeneralFailure("No MFA methods found")

Interesting. Have you configured MFA methods for that specific user? If so, it's likely due to the naming of the MFA method (it wouldn't be the first time I missed one of them!). Let's enable debug, and see what is spit out:

use himmelblau::{PublicClientApplication, BROKER_APP_ID};
use himmelblau::error::MsalError;
use rpassword::read_password;
use std::io;
use std::io::Write;
use std::thread::sleep;
use std::time::Duration;
use tracing::Level;
use tracing_subscriber::FmtSubscriber;

#[tokio::main]
async fn main() {
    let subscriber = FmtSubscriber::builder()
        .with_max_level(Level::TRACE)
        .finish();

    tracing::subscriber::set_global_default(subscriber)
        .expect("Failed setting up default tracing subscriber.");

    let mut username = String::new();
    print!("Please enter your EntraID username: ");
    io::stdout().flush().unwrap();
    io::stdin()
        .read_line(&mut username)
        .expect("Failed to read username");
    username = username.trim().to_string();

    let app = PublicClientApplication::new(BROKER_APP_ID, None).expect("Failed creating app");

    print!("{} password: ", &username);
    io::stdout().flush().unwrap();
    let password = match read_password() {
        Ok(password) => password,
        Err(e) => {
            println!("{:?}", e);
            return ();
        }
    };

    let mut mfa_req = match app
        .initiate_acquire_token_by_mfa_flow(&username, &password, vec![], None)
        .await
    {
        Ok(mfa) => mfa,
        Err(e) => {
            println!("{:?}", e);
            return ();
        }
    };
    println!("{}", mfa_req.msg);

    let _token = match mfa_req.mfa_method.as_str() {
        "PhoneAppOTP" | "OneWaySMS" | "ConsolidatedTelephony" => {
            let input = match read_password() {
                Ok(password) => password,
                Err(e) => {
                    println!("{:?} ", e);
                    return ();
                }
            };
            match app
                .acquire_token_by_mfa_flow(&username, Some(&input), None, &mut mfa_req)
                .await
            {
                Ok(token) => token,
                Err(e) => {
                    println!("MFA FAIL: {:?}", e);
                    return ();
                }
            }
        }
        _ => {
            let mut poll_attempt = 1;
            let polling_interval = mfa_req.polling_interval.unwrap_or(5000);
            loop {
                match app
                    .acquire_token_by_mfa_flow(&username, None, Some(poll_attempt), &mut mfa_req)
                    .await
                {
                    Ok(token) => break token,
                    Err(e) => match e {
                        MsalError::MFAPollContinue => {
                            poll_attempt += 1;
                            sleep(Duration::from_millis(polling_interval.into()));
                            continue;
                        }
                        e => {
                            println!("MFA FAIL: {:?}", e);
                            return ();
                        }
                    },
                }
            }
        }
    };
}
dmulder commented 3 days ago

Well, as I look closer at the libhimmelblau code, I realize you should only get that particular error message if the user doesn't have MFA configured. You are aware you have to manually configure MFA for each user? Also, I haven't implemented a flow for enrolling a user in MFA. That must be done (via a browser) prior to attempting to authenticate via himmelblau.

run-stop commented 3 days ago

Here's the debug output. I believe the MFA is properly enabled, as I'm testing the code using my corp account that works by all means through the MFA. I went through the MFA enrollment with browser, etc.

2024-09-16T16:05:34.116032Z TRACE hyper_util::client::legacy::pool: checkout waiting for idle connection: ("https", login.microsoftonline.com)
2024-09-16T16:05:34.116231Z TRACE hyper_util::client::legacy::connect::http: Http::connect; scheme=Some("https"), host=Some("login.microsoftonline.com"), port=None
2024-09-16T16:05:34.116974Z DEBUG resolve{host=login.microsoftonline.com}: hyper_util::client::legacy::connect::dns: resolving host="login.microsoftonline.com"
2024-09-16T16:05:34.131261Z DEBUG hyper_util::client::legacy::connect::http: connecting to 40.126.32.136:443
2024-09-16T16:05:34.137406Z DEBUG hyper_util::client::legacy::connect::http: connected to 40.126.32.136:443
2024-09-16T16:05:34.156522Z TRACE hyper_util::client::legacy::client: http1 handshake complete, spawning background dispatcher task
2024-09-16T16:05:34.157135Z TRACE hyper_util::client::legacy::pool: checkout dropped for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.205051Z TRACE hyper_util::client::legacy::pool: put; add idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.205150Z DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.212527Z TRACE hyper_util::client::legacy::pool: take? ("https", login.microsoftonline.com): expiration = Some(90s)
2024-09-16T16:05:34.212589Z DEBUG hyper_util::client::legacy::pool: reuse idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.390916Z TRACE hyper_util::client::legacy::pool: put; add idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.390993Z DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.391774Z TRACE hyper_util::client::legacy::pool: take? ("https", login.microsoftonline.com): expiration = Some(90s)
2024-09-16T16:05:34.391844Z DEBUG hyper_util::client::legacy::pool: reuse idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.487550Z TRACE hyper_util::client::legacy::pool: put; add idle connection for ("https", login.microsoftonline.com)
2024-09-16T16:05:34.487662Z DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("https", login.microsoftonline.com)
GeneralFailure("Auth config was not found")
dmulder commented 3 days ago
GeneralFailure("Auth config was not found")

Oh, that's a different error this time.

run-stop commented 3 days ago
GeneralFailure("Auth config was not found")

Oh, that's a different error this time.

"Auth config was not found" was the original message that I got today while testing. Not sure what happened with this "MFA error". Apologies for confusion.

Just figured out: when I mistype the password - program fails with "No MFA methods found". When I punch in a proper password the error is "Auth config was not found".

dmulder commented 3 days ago

Just figured out: when I mistype the password - program fails with "No MFA methods found". When I punch in a proper password the error is "Auth config was not found".

Ah, that makes more sense. Would you be able to try some test code with extra debug? You'd just need to check out a libhimmelblau branch, then in your Cargo.toml, set libhimmelblau = { path = "./path/to/libhimmelblau" }, instead of the version number.

dmulder commented 3 days ago

Here is a little extra debug thrown into a branch: https://gitlab.com/samba-team/libhimmelblau/-/tree/dmulder/run-stop_debugging Just be aware the 'Contents of the auth config response' lines may contain confidential details, so you might need to filter some of the debug.

run-stop commented 3 days ago

Sure! I did this today and added Debug to all structs. Then I used dbg!() statements to figure out what's happening.

I got the library from "https://gitlab.com/samba-team/libhimmelblau.git". Modifed the Cargo.toml to point to the downloaded code.

Then I commented out the line 89 in the libhimmelblau/src/auth.rs:89:8 (JoinPayload struct).

I can run the code with crate from the path. Is there some "TRACE" flag to enable in the library code, or should we do this "the hard way" by running the dbg!() somewhere in the code?

dmulder commented 3 days ago

I'm just throwing in some extra debug lines. I want to see if the auth config is failing to parse because there is some unique response I don't recognize.

run-stop commented 3 days ago

Just let me know where & what do you want. BTW, if you have/prefer some other communication channel while debugging, let me know.

dmulder commented 3 days ago

https://gitlab.com/samba-team/libhimmelblau/-/tree/dmulder/run-stop_debugging

If you want, we can chat on matrix. @dmulder:matrix.org

dmulder commented 3 days ago

Or you could join the matrix channel for himmelblau and we can chat there: https://matrix.to/#/#himmelblau:matrix.org