Skip to content

Commit e553460

Browse files
committed
Add outlook_oauth2 example addressing jonhoo#283
1 parent ea65676 commit e553460

File tree

11 files changed

+1285
-15
lines changed

11 files changed

+1285
-15
lines changed

Cargo.lock

Lines changed: 999 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ ouroboros = "0.18.0"
3737
lettre = "0.11"
3838
rustls-connector = "0.19.0"
3939
clap = { version = "4.5.4", features = ["derive"] }
40+
tokio = { version = "1.37.0", features = ["rt-multi-thread"] }
41+
serde = { version = "1.0.200", features = ["derive"] }
42+
axum = "0.7.5"
43+
axum-server = { version = "0.6", features = ["tls-rustls"] }
44+
oauth2 = { version = "4.4.2", features = [] }
45+
dotenvy = "0.15.7"
4046

4147
# to make -Zminimal-versions work
4248
[target.'cfg(any())'.dependencies]
@@ -67,6 +73,10 @@ required-features = ["default"]
6773
name = "timeout"
6874
required-features = ["default"]
6975

76+
[[example]]
77+
name = "outlook_oauth2"
78+
required-features = ["default"]
79+
7080
[[test]]
7181
name = "imap_integration"
7282
required-features = ["default"]

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This directory contains examples of working with the IMAP client.
66
Examples:
77
* basic - This is a very basic example of using the client.
88
* gmail_oauth2 - This is an example using oauth2 for logging into gmail as a secure appplication.
9+
* outlook_oauth2 - Similar to gmail example, but for outlook.
910
* idle - This is an example showing how to use IDLE to monitor a mailbox.
1011
* rustls - This demonstrates how to use Rustls instead of Openssl for secure connections (helpful for cross compilation).
1112
* starttls - This is an example showing how to use STARTTLS after connecting over plaintext.

examples/outlook_oauth2/.env.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
AZURE_OAUTH_APP_TENANT_ID=4******8-cc46-4d4b-83b2-f************a # or 'consumers' (see README.md)
2+
AZURE_OAUTH_APP_CLIENT_ID=f******8-1914-40fa-baa6-8************2
3+
AZURE_OAUTH_APP_CLIENT_SECRET=fe***************************************hcvr
4+
EMAIL_ADDRESS=[email protected] # or personal address (see README.md)

examples/outlook_oauth2/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.env

examples/outlook_oauth2/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Prerequisites
2+
3+
To run this example, you will need a registered single-tenant OAuth application at `Azure`. Follow [this guide](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth) if you do not have one just yet. Note that the example was created against a single-tenant OAuth app (i.e. for email addresses belonging to your organisation), so make sure to select an appropriate option when registering your application. Alternatively, you can create an app for personal microsoft accounts, but then make sure to specify `AZURE_OAUTH_APP_TENANT_ID=consumers` and `EMAIL_ADDRESS=<[email protected]>` in your `.env` file (read further).
4+
5+
Important! When registering your app (or in the app's settings afterwards), specify `https://localhost:3993` as a redirect url and API permissions for the app should be at least `IMAP.AccessAsUser.All`. Also make sure to select `ID tokens (used for implicit and hybrid flows)` in the app's authentication settings.
6+
7+
Create a `.env` file in this example's directory filling in the required data (`.env.sample` has got an exhaustive list of variables needed for this example to work).
8+
9+
You can now hit `cargo run --example outlook_oauth2` from the repo's root and visit `https://localhost:3993` in your browser. You will want to tell your browser to _dangerously_ trust the self-signed certificates for this domain, but this is _only_ for testing / demo purposes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDazCCAlMCFAxQwXkfT4M84/fevISct//qQskRMA0GCSqGSIb3DQEBCwUAMHEx
3+
CzAJBgNVBAYTAlVaMREwDwYDVQQIDAhUYXNoa2VudDERMA8GA1UEBwwIVGFzaGtl
4+
bnQxEzARBgNVBAoMCmZha3RvcnktcnMxEzARBgNVBAsMCmZha3RvcnktcnMxEjAQ
5+
BgNVBAMMCWxvY2FsaG9zdDAgFw0yNDAyMDMyMDI1MDlaGA8zMDA0MDQwNjIwMjUw
6+
OVowcTELMAkGA1UEBhMCVVoxETAPBgNVBAgMCFRhc2hrZW50MREwDwYDVQQHDAhU
7+
YXNoa2VudDETMBEGA1UECgwKZmFrdG9yeS1yczETMBEGA1UECwwKZmFrdG9yeS1y
8+
czESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
9+
CgKCAQEA4ektheqTRy+eHn9j22AxGHqtg/elEiZC0UCLX51ysEkhnLLvFlVFtzd7
10+
q+nx1PNiHdH5i/TjdAYrXAZhKU/k2YfrgCyOjm/XxSw7ujXPP+cWOmdRYTexT9o7
11+
Yrg3ZYMniJbbTl8j37dieXHaO7FHAvpww1q/nbQkwD/1WqK1ggQY/OZ38wpUvsws
12+
9LA7shuXdGnjAXunnRGEzZ2EG6T5hYw0PFL+2CHwr0lqNbCur8wu99t4ED9/vfLG
13+
0TWRQwSnApyjHy89rn5Ze3vOiNzcBW778oZxwvzriEmbQQg6RxKE19AlaiV4+n5S
14+
woAi8Ji69BKRUSlxRhW6eX4ABV2eOwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQDS
15+
EXuIvVx27LyWlIhfY6vwSWqeUoRXmMFpiBNTTvvHQKlJzLlDyn1b+CqHvMdE9RZh
16+
FI5shZkiqtRRTUGVHB4o0ntwCQmWyV/5FQQ6EYs/bHXUcN2vt1XuU7WK4fRafPPu
17+
snYDgg0TmpGvm+J8W64TfJogWqpPsnT4pOF+aNqW88TTs1JUnNFDBQmw2QKBK+AH
18+
+V4zhpCjVXpKtVMTnDWHQfJh4whelD18lU1jPCbzQrRs2hQWQvtzKWi0YCYc1IXl
19+
4E6eIOHRuiUl/mE3p3f2CGJIwxgrMuxN07ncnwVXBPCaVzSLWJHy0G61mFKH5R/7
20+
42EC7S/POk5GtzkMJ5Du
21+
-----END CERTIFICATE-----
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6S2F6pNHL54e
3+
f2PbYDEYeq2D96USJkLRQItfnXKwSSGcsu8WVUW3N3ur6fHU82Id0fmL9ON0Bitc
4+
BmEpT+TZh+uALI6Ob9fFLDu6Nc8/5xY6Z1FhN7FP2jtiuDdlgyeIlttOXyPft2J5
5+
cdo7sUcC+nDDWr+dtCTAP/VaorWCBBj85nfzClS+zCz0sDuyG5d0aeMBe6edEYTN
6+
nYQbpPmFjDQ8Uv7YIfCvSWo1sK6vzC7323gQP3+98sbRNZFDBKcCnKMfLz2ufll7
7+
e86I3NwFbvvyhnHC/OuISZtBCDpHEoTX0CVqJXj6flLCgCLwmLr0EpFRKXFGFbp5
8+
fgAFXZ47AgMBAAECggEAJjyV4G86O1fDbw0HxUdMOAT3nnkJfv9r2sgObwISueS+
9+
5CtjDUgkkyS4cXoY3P7O0hZKoxYxc19h8mMACgKETQ9U3G5uOIyUnEJm35cg+4Ns
10+
/ziijQ5knAvndkeQ1MU0qUlDWEoBI+oBqGWNVwIj70ydTmtrOFGX0NRiflNA3n7q
11+
pJbdRZzKnTxXxRwIRuGA1y6SlBLQ740hVOm56iLtRJ+P0kNErSL8Uhws/X9/0MXH
12+
W8r2JVikNumBZH18MK+wBGulwZBcLurFfv31hbeQ/FnckOJ1OE53rnV+tBrZN7Ap
13+
6eR4IMcVPfunnGX+meEUnJfmC0HrdQXucDB8Ey/biQKBgQDygP0JeUKpSWX2uSfV
14+
2c8N0opmC2uHswOhf+H9TOyA4DO5NmlbOqVv+uUwRQvIkoen8XNMCPOyoK7WZNAB
15+
hfyU+ck3HDIBqHbGBisUXDNLgIQIhWVznYK0QC+YYr+rEmFun0sMriuhZsU1q2mW
16+
VoAPSTJhaufRb0TKib9Tarzg4wKBgQDue8jk0tbK5xL9dcyn1CxHtDAbfyQfQnSd
17+
G+GcQDDCamgbKI042A5lPSToYEOpSMTOn/n5CmezsSMFnwuwZAgQ1Pbd3YeknBCi
18+
6jWzqYcC11u3EeX9YPJgEDZq0uSWNZg0phDBsu+PYq7vDAriCsMeQrLMvQb0Fs3n
19+
Pp4vVzSEyQKBgQCb+h1G/6jBzAT6WYNmyE6mPFpqYkQKpzjZorCPxO+FwS9jnLzN
20+
Qf5w9TZ/Apoeqyj3+5RGPqfIqBNssLEdmbmpdLRYbxk2+c1Td1o0IU2Y7ZN/C5YC
21+
dDhCidpTMIjJluv2RBz4jfpgOQL1j0g9u2to6ZKvGBz9F41unITkOY49MwKBgEzk
22+
1qqJHL6BcQsOT3WRoNFh1N0YyoHVwJnjooPp4o7dFkIjeh1o9INKCrtuRoKvtt1U
23+
kZnt8+/pXnxygqdWKY+byxlQU2sM8wREdho+wAx3edf2Smy/NIcq0xDwfMm98ByR
24+
qvd5hWp7DCKBhITLqYv5P4NqM3LCY5N7CjADcyiZAoGBALXXR5WSHLjtzaN4Eeti
25+
pWur1VN30HiM2zRTXwTxx6X7y/FI5xzoCVAJb6tSpC/aXzFx05Xa/LyhDXI2sbhm
26+
G3a4tjBRrief5z8XQ7gdBSiyRtLc1XFy3kmeN2HTPMWSIrbk56xyEOqbXov5S+41
27+
hWwNT3lodEZ2ymFWEZHHAvhb
28+
-----END PRIVATE KEY-----

examples/outlook_oauth2/main.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use axum::extract::{Query, State};
2+
use axum::response::Html;
3+
use axum::routing::get;
4+
use axum::Router;
5+
use axum_server::tls_rustls::RustlsConfig;
6+
use oauth2::AccessToken;
7+
use serde::Deserialize;
8+
use std::sync::Arc;
9+
use std::{env, net::SocketAddr, path::PathBuf};
10+
11+
mod state;
12+
mod utils;
13+
14+
struct OutlookOAuth2 {
15+
user: String,
16+
access_token: AccessToken,
17+
}
18+
19+
impl imap::Authenticator for OutlookOAuth2 {
20+
type Response = String;
21+
#[allow(unused_variables)]
22+
fn process(&self, data: &[u8]) -> Self::Response {
23+
format!(
24+
"user={}\x01auth=Bearer {}\x01\x01",
25+
self.user,
26+
self.access_token.secret()
27+
)
28+
}
29+
}
30+
31+
#[tokio::main]
32+
async fn main() {
33+
// load environment variables
34+
let this_example_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35+
.join("examples")
36+
.join("outlook_oauth2");
37+
dotenvy::from_path(this_example_dir.join(".env")).ok();
38+
39+
// load self-signed certificates
40+
let certs_dir = this_example_dir.join("certs");
41+
let config = RustlsConfig::from_pem_file(
42+
certs_dir.join("imap.local.crt"),
43+
certs_dir.join("imap.local.key"),
44+
)
45+
.await
46+
.unwrap();
47+
48+
// define application and ...
49+
let state = Arc::new(state::AppState::default());
50+
let app = Router::new().route("/", get(home)).with_state(state);
51+
52+
// ... launch it
53+
axum_server::bind_rustls(SocketAddr::from(([127, 0, 0, 1], 3993)), config)
54+
.serve(app.into_make_service())
55+
.await
56+
.unwrap();
57+
}
58+
59+
#[derive(Deserialize)]
60+
struct AuthCode {
61+
code: String,
62+
state: String,
63+
}
64+
65+
async fn home(
66+
auth_code: Option<Query<AuthCode>>,
67+
State(state): State<Arc<state::AppState>>,
68+
) -> Html<String> {
69+
// already redirected back to homepage by the authority
70+
// with a code in query string parameters
71+
if let Some(auth_code) = auth_code {
72+
assert!(!auth_code.state.is_empty());
73+
// exchange the code they brought from the authority for an access token
74+
let access_token =
75+
utils::exchange_code_for_token(&state.oauth_client, auth_code.code.clone()).await;
76+
// instantiate an authenticator
77+
let outlook_oauth = OutlookOAuth2 {
78+
user: env::var("EMAIL_ADDRESS").unwrap(),
79+
access_token,
80+
};
81+
// establish a connection to the IMAP server
82+
let client = imap::ClientBuilder::new("outlook.office365.com", 993)
83+
.connect()
84+
.expect("successfly connected");
85+
// start a session
86+
let mut session = client
87+
.authenticate("XOAUTH2", &outlook_oauth)
88+
.expect("authenticated connection");
89+
// fetch the first email in the inbox
90+
session.select("INBOX").unwrap();
91+
let messages = session.fetch("1", "RFC822").unwrap();
92+
let first_message = messages.iter().next().unwrap().body().unwrap();
93+
let first_message = std::str::from_utf8(first_message).unwrap();
94+
// end the session
95+
session.logout().unwrap();
96+
// render the contents of the fetched email
97+
let page = format!(
98+
r#"
99+
<!doctype html>
100+
<html lang="en">
101+
<head>
102+
<title>Home</title>
103+
<meta name="viewport" content="width=device-width">
104+
</head>
105+
<body>
106+
<span>First message in your inbox</span>
107+
<span>{:?}</span>
108+
</body>
109+
</html>
110+
"#,
111+
first_message,
112+
);
113+
return Html(page);
114+
}
115+
// should go and log in to their microsoft account (if not already logged in)
116+
// and explicitly authorize the OAuth application, after which the authority
117+
// will issue a code that can be later on echanged for an access token
118+
let url = utils::build_auth_url(&state.oauth_client);
119+
let page = format!(
120+
r#"
121+
<!doctype html>
122+
<html lang="en">
123+
<head>
124+
<title>Home</title>
125+
<meta name="viewport" content="width=device-width">
126+
</head>
127+
<body>
128+
<a href="{}">Show my fist letter</a>
129+
</body>
130+
</html>
131+
"#,
132+
url.to_string()
133+
);
134+
Html(page)
135+
}

examples/outlook_oauth2/state.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use oauth2::basic::{BasicErrorResponseType, BasicTokenType};
2+
use oauth2::{
3+
Client, EmptyExtraTokenFields, RevocationErrorResponseType, StandardErrorResponse,
4+
StandardRevocableToken, StandardTokenIntrospectionResponse, StandardTokenResponse,
5+
};
6+
7+
pub(crate) type OauthClient = Client<
8+
StandardErrorResponse<BasicErrorResponseType>,
9+
StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>,
10+
BasicTokenType,
11+
StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>,
12+
StandardRevocableToken,
13+
StandardErrorResponse<RevocationErrorResponseType>,
14+
>;
15+
16+
pub(crate) struct AppState {
17+
pub(crate) oauth_client: OauthClient,
18+
}
19+
20+
impl Default for AppState {
21+
fn default() -> Self {
22+
Self {
23+
oauth_client: super::utils::build_oauth_client(),
24+
}
25+
}
26+
}

examples/outlook_oauth2/utils.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use oauth2::basic::BasicClient;
2+
use oauth2::reqwest::async_http_client;
3+
use oauth2::url::Url;
4+
use oauth2::AccessToken;
5+
use oauth2::TokenResponse;
6+
use oauth2::{
7+
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, Scope, TokenUrl,
8+
};
9+
use std::env;
10+
11+
pub(crate) fn build_auth_url(client: &super::state::OauthClient) -> Url {
12+
let (url, _csrf_token) = client
13+
.authorize_url(CsrfToken::new_random)
14+
.add_scope(Scope::new(
15+
"https://outlook.office.com/IMAP.AccessAsUser.All".to_string(),
16+
))
17+
.url();
18+
url
19+
}
20+
21+
pub(crate) fn build_oauth_client() -> super::state::OauthClient {
22+
let client_id = ClientId::new(env::var("AZURE_OAUTH_APP_CLIENT_ID").unwrap());
23+
let client_secret = ClientSecret::new(env::var("AZURE_OAUTH_APP_CLIENT_SECRET").unwrap());
24+
let tenant_id = env::var("AZURE_OAUTH_APP_TENANT_ID").unwrap();
25+
let auth_url = AuthUrl::new(format!(
26+
"https://login.microsoftonline.com/{}/oauth2/v2.0/authorize",
27+
tenant_id
28+
))
29+
.expect("valid url");
30+
let token_url = TokenUrl::new(format!(
31+
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
32+
tenant_id
33+
))
34+
.expect("valid url");
35+
BasicClient::new(client_id, Some(client_secret), auth_url, Some(token_url)).set_redirect_uri(
36+
RedirectUrl::new("https://localhost:3993".to_string()).expect("valid url"),
37+
)
38+
}
39+
40+
pub(crate) async fn exchange_code_for_token(
41+
client: &super::state::OauthClient,
42+
code: String,
43+
) -> AccessToken {
44+
client
45+
.exchange_code(AuthorizationCode::new(code))
46+
.request_async(async_http_client)
47+
.await
48+
.expect("response with token")
49+
.access_token()
50+
.to_owned()
51+
}

0 commit comments

Comments
 (0)