Skip to content

Commit 019e7d6

Browse files
authored
Merge pull request #40 from robn/auth-bearer
Add support for HTTP Bearer authentication
2 parents 9880f59 + cdd59af commit 019e7d6

File tree

3 files changed

+115
-58
lines changed

3 files changed

+115
-58
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Support for bearer token authentication (#40)
10+
811
### Changed
912
- mujmap now prints a more comprehensive guide on how to recover from a missing
1013
state file. (#15)

mujmap.toml.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
################################################################################
22
## Required config
33

4-
## Username for basic HTTP authentication.
4+
## Account username. Used for authentication to the server.
55

66
username = "[email protected]"
77

8-
## Shell command which will print a password to stdout for basic HTTP
9-
## authentication.
8+
## Shell command which will print a password or token to stdout for
9+
## authentication. You service provider might call this an "app password" or
10+
## "API token".
1011

1112
password_command = "pass [email protected]"
1213

src/remote.rs

Lines changed: 108 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ pub enum Error {
5353
source: ureq::Error,
5454
},
5555

56+
#[snafu(display("Session username doesn't match configured username: {}", username))]
57+
UsernameMismatch { username: String },
58+
5659
#[snafu(display("Could not complete API request: {}", source))]
5760
Request { source: ureq::Error },
5861

@@ -99,37 +102,34 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
99102

100103
struct HttpWrapper {
101104
/// Value of HTTP Authorization header.
102-
authorization: String,
105+
authorization: Option<String>,
103106
/// Persistent ureq agent to use for all HTTP requests.
104107
agent: ureq::Agent,
105108
}
106109

107110
impl HttpWrapper {
108-
fn new(username: &str, password: &str, timeout: u64) -> Self {
109-
let safe_username = match username.find(':') {
110-
Some(idx) => &username[..idx],
111-
None => username,
112-
};
113-
let authorization = format!(
114-
"Basic {}",
115-
base64::encode(format!("{}:{}", safe_username, password))
116-
);
111+
fn new(authorization: Option<String>, timeout: u64) -> Self {
117112
let agent = ureq::AgentBuilder::new()
118113
.redirect_auth_headers(ureq::RedirectAuthHeaders::SameHost)
119114
.timeout(Duration::from_secs(timeout))
120115
.build();
121116

122117
Self {
123-
agent,
124118
authorization,
119+
agent,
120+
}
121+
}
122+
123+
fn apply_authorization(&self, req: ureq::Request) -> ureq::Request {
124+
match &self.authorization {
125+
Some(authorization) => req.set("Authorization", authorization),
126+
_ => req,
125127
}
126128
}
127129

128130
fn get_session(&self, session_url: &str) -> Result<(String, jmap::Session), ureq::Error> {
129131
let response = self
130-
.agent
131-
.get(session_url)
132-
.set("Authorization", &self.authorization)
132+
.apply_authorization(self.agent.get(session_url))
133133
.call()?;
134134

135135
let session_url = response.get_url().to_string();
@@ -139,9 +139,7 @@ impl HttpWrapper {
139139

140140
fn get_reader(&self, url: &str) -> Result<impl Read + Send> {
141141
Ok(self
142-
.agent
143-
.get(url)
144-
.set("Authorization", &self.authorization)
142+
.apply_authorization(self.agent.get(url))
145143
.call()
146144
.context(ReadEmailBlobSnafu {})?
147145
.into_reader()
@@ -152,9 +150,7 @@ impl HttpWrapper {
152150

153151
fn post_string<D: DeserializeOwned>(&self, url: &str, body: &str) -> Result<D> {
154152
let post = self
155-
.agent
156-
.post(url)
157-
.set("Authorization", &self.authorization)
153+
.apply_authorization(self.agent.post(url))
158154
.send_string(body)
159155
.context(RequestSnafu {})?;
160156
if log_enabled!(log::Level::Trace) {
@@ -168,9 +164,7 @@ impl HttpWrapper {
168164

169165
fn post_json<S: Serialize, D: DeserializeOwned>(&self, url: &str, body: S) -> Result<D> {
170166
let post = self
171-
.agent
172-
.post(url)
173-
.set("Authorization", &self.authorization)
167+
.apply_authorization(self.agent.post(url))
174168
.send_json(body)
175169
.context(RequestSnafu {})?;
176170
if log_enabled!(log::Level::Trace) {
@@ -194,7 +188,8 @@ pub struct Remote {
194188
impl Remote {
195189
pub fn open(config: &Config) -> Result<Self> {
196190
let password = config.password().context(GetPasswordSnafu {})?;
197-
match (&config.fqdn, &config.session_url) {
191+
192+
let remote = match (&config.fqdn, &config.session_url) {
198193
(Some(fqdn), _) => {
199194
Self::open_host(&fqdn, config.username.as_str(), &password, config.timeout)
200195
}
@@ -211,10 +206,19 @@ impl Remote {
211206
.context(NoDomainNameSnafu {})?;
212207
Self::open_host(domain, config.username.as_str(), &password, config.timeout)
213208
}
214-
}
209+
}?;
210+
211+
ensure!(
212+
remote.session.username == config.username,
213+
UsernameMismatchSnafu {
214+
username: remote.session.username
215+
}
216+
);
217+
218+
Ok(remote)
215219
}
216220

217-
pub fn open_host(fqdn: &str, username: &str, password: &str, timeout: u64) -> Result<Self> {
221+
fn open_host(fqdn: &str, username: &str, password: &str, timeout: u64) -> Result<Self> {
218222
let resolver = Resolver::from_system_conf().context(ParseResolvConfSnafu {})?;
219223
let mut address = format!("_jmap._tcp.{}", fqdn);
220224
if !address.ends_with(".") {
@@ -224,8 +228,6 @@ impl Remote {
224228
.srv_lookup(address.as_str())
225229
.context(SrvLookupSnafu { address })?;
226230

227-
let http_wrapper = HttpWrapper::new(username, password, timeout);
228-
229231
// Try all SRV names in order of priority.
230232
let mut last_err = None;
231233
for name in resolver_response
@@ -238,38 +240,89 @@ impl Remote {
238240
target.pop();
239241

240242
let url = format!("https://{}:{}/.well-known/jmap", target, name.port());
241-
match http_wrapper.get_session(url.as_str()) {
242-
Ok((session_url, session)) => {
243-
return Ok(Remote {
244-
http_wrapper,
245-
session_url,
246-
session,
247-
})
248-
}
249-
250-
Err(e) => last_err = Some((url, e)),
243+
match Self::open_url(url.as_str(), username, password, timeout) {
244+
Ok(s) => return Ok(s),
245+
Err(e) => last_err = Some(e),
251246
};
252247
}
253248
// All of them failed! Return the last error.
254-
let (session_url, error) = last_err.unwrap();
255-
Err(error).context(OpenSessionSnafu { session_url })
249+
Err(last_err.unwrap())
256250
}
257251

258-
pub fn open_url(
259-
session_url: &str,
260-
username: &str,
261-
password: &str,
262-
timeout: u64,
263-
) -> Result<Self> {
264-
let http_wrapper = HttpWrapper::new(username, password, timeout);
265-
let (session_url, session) = http_wrapper
266-
.get_session(session_url)
267-
.context(OpenSessionSnafu { session_url })?;
268-
Ok(Remote {
269-
http_wrapper,
270-
session_url,
271-
session,
272-
})
252+
fn open_url(session_url: &str, username: &str, password: &str, timeout: u64) -> Result<Self> {
253+
let agent = ureq::AgentBuilder::new()
254+
.redirect_auth_headers(ureq::RedirectAuthHeaders::SameHost)
255+
.timeout(Duration::from_secs(timeout))
256+
.build();
257+
258+
match agent.get(session_url).call() {
259+
Ok(r) => {
260+
// Server returned success without authentication. Surprising, but valid.
261+
let session_url = r.get_url().to_string();
262+
let session: jmap::Session = r.into_json().context(ResponseSnafu {})?;
263+
Ok(Self {
264+
http_wrapper: HttpWrapper::new(None, timeout),
265+
session_url,
266+
session,
267+
})
268+
}
269+
270+
Err(ureq::Error::Status(code, ref r)) if code == 401 => {
271+
fn encode_basic(username: &str, password: &str) -> String {
272+
let safe_username = match username.find(':') {
273+
Some(idx) => &username[..idx],
274+
None => username,
275+
};
276+
format!(
277+
"Basic {}",
278+
base64::encode(format!("{}:{}", safe_username, password))
279+
)
280+
}
281+
282+
let authorization = match r.header("WWW-Authenticate") {
283+
Some(v) if v.starts_with("Basic") => {
284+
debug!("server offered Basic auth");
285+
Some(encode_basic(username, password))
286+
}
287+
288+
Some(v) if v.starts_with("Bearer") => {
289+
debug!("server offered Bearer auth");
290+
Some(format!("Bearer {}", password))
291+
}
292+
293+
// Server didn't offer any auth schemes but still requires authentication.
294+
// Probably it will accept Basic; try that.
295+
None => {
296+
debug!("server requires auth but didn't offer a scheme, assuming Basic");
297+
Some(encode_basic(username, password))
298+
}
299+
300+
// No authorization, which will make the next call fail, and then we'll just
301+
// return an error.
302+
Some(v) => {
303+
debug!("server offered unsupported auth scheme: {}", v);
304+
None
305+
}
306+
};
307+
308+
let url = r.get_url();
309+
310+
let mut req = agent.get(url);
311+
if let Some(a) = &authorization {
312+
req = req.set("Authorization", a);
313+
}
314+
315+
let r = req.call().context(OpenSessionSnafu { session_url })?;
316+
let session: jmap::Session = r.into_json().context(ResponseSnafu {})?;
317+
Ok(Self {
318+
http_wrapper: HttpWrapper::new(authorization, timeout),
319+
session_url: url.to_string(),
320+
session,
321+
})
322+
}
323+
324+
Err(e) => Err(e).context(OpenSessionSnafu { session_url }),
325+
}
273326
}
274327

275328
/// Return a list of all `Email` IDs that exist on the server and a state `String` returned by

0 commit comments

Comments
 (0)