MSAL
====
The purpose of this project is to implement MSAL for Rust, based on the specifications found in the Microsoft API Reference for [ClientApplication Class](https://learn.microsoft.com/en-us/python/api/msal/msal.application.clientapplication?view=msal-py-latest) and [PublicClientApplication Class](https://learn.microsoft.com/en-us/python/api/msal/msal.application.publicclientapplication?view=msal-py-latest). These are Python references which will be mimicked in Rust here.
The project also implements the [MS-DRS] protocol, which is undocumented by
microsoft. A [protocol specification](https://github.com/himmelblau-idm/aad-join-spec/releases/latest)
is in progress as part of the himmelblau project.
In addition to the ClientApplication Class and [MS-DRS] implementations, this project implements [MS-OAPXBC] sections [3.1.5.1.2 Request for Primary Refresh Token](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-oapxbc/d32d5cd0-05d4-4ec2-8bcc-ac29ce711c23) and [3.1.5.1.3 Exchange Primary Refresh Token for Access Token](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-oapxbc/06e2bf0d-8cea-4b11-ad78-d212330ebda9). These are not implemented in Microsoft's MSAL libraries, but are possible when authenticating from an enrolled device.
How do I use this library?
--------------------------
Import the module into your project, then include the PublicClientApplication:
```Rust
use msal::PublicClientApplication;
```
Create an instance of the PublicClientApplication, then authenticate:
```Rust
let authority = format!("https://login.microsoftonline.com/{}", tenant_id);
let app = PublicClientApplication::new(client_id, Some(&authority)).expect("Failed creating app");
let scope = vec![];
let token = app.acquire_token_by_username_password(username, password, scope).await?;
```
You can obtain your `client_id` and `tenant_id` from the Azure portal.
You can perform a silent auth using a previously obtained refresh token:
```Rust
let token = app.acquire_token_silent(scope, &token.refresh_token).await?;
```
Or finally, you can perform a Device Authorization Grant:
```Rust
let flow = app.initiate_device_flow(scope).await?;
// Prompt the user with the message found in flow.message
let token = app.acquire_token_by_device_flow(flow).await?;
```
If msal is built with the `broker` feature, you can enroll the device, then request an authentication token:
```Rust
use kanidm_hsm_crypto::soft::SoftTpm;
use kanidm_hsm_crypto::{BoxedDynTpm, Tpm, AuthValue};
// First create the TPM object and a machine_key
let mut tpm = BoxedDynTpm::new(SoftTpm::new());
let auth_str = AuthValue::generate().expect("Failed to create hex pin");
let auth_value = AuthValue::from_str(&auth_str).expect("Unable to create auth value");
let loadable_machine_key = tpm
.machine_key_create(&auth_value)
.expect("Unable to create new machine key");
let machine_key = tpm
.machine_key_load(&auth_value, &loadable_machine_key)
.expect("Unable to load machine key");
let app = BrokerClientApplication::new(Some(&authority), None, None).expect("Failed creating app");
// Obtain a token for authentication. If authenticating here without MFA, the PRT and
// user token will not have the mfa claim. Use initiate_device_flow_for_device_enrollment()
// and acquire_token_by_device_flow() to authenticate with the
// mfa claim.
let token = app.acquire_token_by_username_password_for_device_enrollment(username, password).await?;
// Specify the attributes which will be used for enrollment
let attrs = match EnrollAttrs::new(
domain.to_string(),
Some("test_machine".to_string()), // Device name
Some("Linux".to_string()), // Device type
Some(0), // Join type
Some("openSUSE Leap 15.5".to_string()), // OS version
) {
Ok(attrs) => attrs,
Err(e) => {
println!("{:?}", e);
return ();
}
};
// Use the tpm for enrollment.
let (transport_key, cert_key, device_id) = app.enroll_device(&token.refresh_token, attrs, &mut tpm, &machine_key).await?;
// Request an authentication token
let token = app.acquire_token_by_username_password(username, password, scope, &mut tpm, &machine_key).await?;
```
In order to initialize a BrokerClientApplication that was previously enrolled, ensure you've cached your `auth_value`, `loadable_machine_key`, `transport_key`, and `cert_key`. The `auth_value` MUST be stored in a secure manor only accessible to your application. Preferably your application should execute as a unique user, and only that user will have read access to the `auth_value`. Re-initialize as follows:
```Rust
let mut tpm = BoxedDynTpm::new(SoftTpm::new());
let loadable_machine_key = tpm
.root_storage_key_create(&auth_value)
.expect("Unable to create new machine key");
let machine_key = tpm
.root_storage_key_load(&auth_value, &loadable_machine_key)
.expect("Unable to load machine key");
let app = BrokerClientApplication::new(Some(&authority), Some(&transport_key), Some(&cert_key)).expect("Failed creating app");
```
Entra OpenSSH certificates
--------------------------
`BrokerClientApplication` can request the same Microsoft-issued OpenSSH user
certificate used by Azure SSH login. The caller generates and retains an
ephemeral RSA private key, then passes its `ssh-rsa` public-key line to
`exchange_prt_for_ssh_certificate`. The returned `access_token` is treated as a
base64-encoded OpenSSH certificate, not as a JWT. Use
`EntraSshCertificate::openssh_certificate()` to obtain the complete
`ssh-rsa-cert-v01@openssh.com` line suitable for an OpenSSH certificate file.
`exchange_prt_for_ssh_certificate` deliberately uses the sealed PRT flow; it
does not expose a refresh-token or interactive fallback. The result also
provides the complete signing CA OpenSSH public key and its SHA-256 fingerprint
for target-side trust decisions.
C callers own the returned certificate until `ssh_certificate_free`, and must
release strings returned by its accessors with `string_free`. Principal arrays
must be released with `ssh_certificate_free_principals` and their exact count.
Python values are managed by Python.
The library does not write key files, invoke `ssh`, or validate target-side CA
trust. Callers remain responsible for secure private-key storage and cleanup.
Using the On-Behalf-Of (OBO) flow
---------------------------------
The OBO flow is available when built with the `on_behalf_of` feature.
Rust (confidential middle-tier service):
```Rust
use msal::{ClientCredential, ConfidentialClientApplication, MsalError};
let authority = format!("https://login.microsoftonline.com/{}", tenant_id);
let credential = ClientCredential::from_secret(client_secret.to_string());
let app = ConfidentialClientApplication::new(client_id, Some(&authority), credential)?;
match app
.acquire_token_on_behalf_of(
user_access_token, // incoming bearer token
vec!["https://graph.microsoft.com/User.Read"],
None,
)
.await
{
Ok(token) => {
println!("access_token={}", token.access_token);
}
Err(MsalError::OboInteractionRequired { claims, .. }) => {
// Return this claims challenge to the original client so it can
// re-authenticate and satisfy Conditional Access.
println!("claims_challenge={:?}", claims);
}
Err(e) => return Err(e),
}
```
C API:
* Initialize with `confidential_client_init_with_secret`. Note: only client secret
credentials are currently supported in the C API; certificate-based credentials
are available from Rust only.
* Exchange the incoming user token with `confidential_acquire_token_on_behalf_of`.
* Compile OBO C callers with `-DON_BEHALF_OF` so OBO declarations are visible in
the generated header.
* Read token fields with:
* `obo_token_access_token`
* `obo_token_token_type`
* `obo_token_expires_in`
* `obo_token_ext_expires_in`
* `obo_token_scope` (returns `NULL` when not present)
* `obo_token_refresh_token` (returns `NULL` when not present)
* On Conditional Access claims challenge, check `MSAL_ERROR.code == OBO_INTERACTION_REQUIRED`
and read `MSAL_ERROR.claims`.
Python API:
* Create `ConfidentialClientApplication(client_id, authority, client_secret)`. Note:
only client secret credentials are currently supported in the Python API;
certificate-based credentials are available from Rust only.
* Call `acquire_token_on_behalf_of(user_assertion, scopes)`.
* Catch `OboInteractionRequiredError` and inspect:
* `claims`
* `error`
* `error_description`
* `error_codes`
* `suberror`
Reference examples:
* `example/msal_obo_example.c`
* `example/msal_obo_example.py`
* `example/msal_obo_end_to_end_test.py` (full functional validation: upstream token -> OBO -> Graph `/me`)
Using the Python API
--------------------
A script that uses `PublicClientApplication` from Python can be found in the [examples](example/msal_public_example.py).
This script uses a public client created via an [Azure App Registration](https://himmelblau-idm.org/docs/advanced/Creating-an-Entra-ID-Application-for-Himmelblau-GroupMember.Read.All-Permissions/). The URL `https://login.microsoftonline.com/common/oauth2/nativeclient` should be used as a "Mobile and desktop applications" Redirect URI under the Authentication settings for the App Registration. The script was tested with the following permissions for the application:
* email
* Group.Read.All
* offline_access
* openid
* profile
* User.Read
The script needs the tenant ID and client ID for the App Registration, and allows logging in with a username and password.
It also demonstrates explicitly choosing an MFA method for the login, rather than using the default MFA method.
To build `libhimmelblau` and test it with this script using (uv)[https://docs.astral.sh/uv/]:
```sh
uv tool install maturin
uv venv && uv pip install patchelf cffi
# build the library with python bindings and install it into the virtual environment
maturin build --features "pyapi,on_behalf_of" && uv pip install --force-reinstall target/wheels/libhimmelblau-*.whl
python example/msal_public_example.py
```