1use core::fmt;
52
53use alloc::{
54 borrow::Cow,
55 string::{String, ToString},
56 vec::Vec,
57};
58
59use base64::{Engine, engine::general_purpose::STANDARD as base64};
60use bounded_static::IntoBoundedStatic;
61use log::trace;
62use secrecy::{ExposeSecret, SecretBox, SecretString};
63use thiserror::Error;
64
65use crate::{
66 coroutine::*,
67 rfc4954::{auth::SmtpAuthCommand, auth_data::SmtpAuthData},
68 rfc5321::{
69 ehlo::{SmtpEhlo, SmtpEhloError},
70 types::{ehlo_domain::EhloDomain, reply_code::ReplyCode},
71 },
72 send::*,
73 smtp_try,
74};
75
76pub const XOAUTH2: &str = "XOAUTH2";
78
79#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct SmtpAuthXoauth2Options {
82 pub initial_request: bool,
85 pub ensure_capabilities: bool,
87}
88
89impl Default for SmtpAuthXoauth2Options {
90 fn default() -> Self {
91 Self {
92 initial_request: true,
93 ensure_capabilities: true,
94 }
95 }
96}
97
98#[derive(Debug, Error)]
100pub enum SmtpAuthXoauth2Error {
101 #[error("SMTP AUTH XOAUTH2 failed: rejected {code} {message}")]
102 Rejected { code: u16, message: String },
103 #[error("SMTP AUTH XOAUTH2 failed: server did not send the expected continuation request")]
104 ExpectedContinuationRequest,
105 #[error("SMTP AUTH XOAUTH2 failed: {0}")]
106 Send(#[from] SendSmtpCommandError),
107 #[error(transparent)]
108 Ehlo(#[from] SmtpEhloError),
109}
110
111pub struct SmtpAuthXoauth2 {
114 state: State,
115 domain: Option<EhloDomain<'static>>,
116 payload: Option<Vec<u8>>,
117 error_detail: Option<String>,
118 opts: SmtpAuthXoauth2Options,
119}
120
121impl SmtpAuthXoauth2 {
122 pub fn new(
123 username: &str,
124 token: &SecretString,
125 domain: EhloDomain<'_>,
126 opts: SmtpAuthXoauth2Options,
127 ) -> Self {
128 let payload = build_payload(username, token);
129
130 let state = if opts.initial_request {
131 let cmd = SmtpAuthCommand {
132 mechanism: Cow::Borrowed(XOAUTH2),
133 initial_response: Some(SecretBox::new(payload.clone().into_boxed_slice())),
134 };
135 State::Send(SendSmtpCommand::new(cmd))
136 } else {
137 let cmd = SmtpAuthCommand {
138 mechanism: Cow::Borrowed(XOAUTH2),
139 initial_response: None,
140 };
141 State::Send(SendSmtpCommand::new(cmd))
142 };
143
144 Self {
145 state,
146 domain: Some(domain.into_static()),
147 payload: Some(payload),
148 error_detail: None,
149 opts,
150 }
151 }
152}
153
154impl SmtpCoroutine for SmtpAuthXoauth2 {
155 type Yield = SmtpYield;
156 type Return = Result<(), SmtpAuthXoauth2Error>;
157
158 fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
159 loop {
160 trace!("auth xoauth2: {}", self.state);
161
162 match &mut self.state {
163 State::Send(send) => {
164 let out = smtp_try!(send, arg);
165
166 if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
167 if self.opts.initial_request {
168 self.advance_after_auth();
169 continue;
170 }
171 return SmtpCoroutineState::Complete(Err(
172 SmtpAuthXoauth2Error::ExpectedContinuationRequest,
173 ));
174 }
175
176 if out.response.code == ReplyCode::AUTH_CONTINUE {
177 if self.opts.initial_request {
178 let text = out.response.text().0.trim_start();
181 if let Ok(detail_bytes) = base64.decode(text.as_bytes()) {
182 self.error_detail = String::from_utf8(detail_bytes).ok();
183 }
184
185 let ack = SmtpAuthData::r#continue(vec![0x01u8]);
186 self.state = State::AckError(SendSmtpCommand::new(ack));
187 continue;
188 }
189
190 let payload = self.payload.take().expect("payload taken twice");
192 let data = SmtpAuthData::r#continue(payload.into_boxed_slice());
193 self.state = State::Continue(SendSmtpCommand::new(data));
194 continue;
195 }
196
197 let code = out.response.code.code();
198 let message = out.response.text().to_string();
199 return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
200 code,
201 message,
202 }));
203 }
204 State::Continue(send) => {
205 let out = smtp_try!(send, arg);
206
207 if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
208 self.advance_after_auth();
209 continue;
210 }
211
212 if out.response.code == ReplyCode::AUTH_CONTINUE {
213 let text = out.response.text().0.trim_start();
214 if let Ok(detail_bytes) = base64.decode(text.as_bytes()) {
215 self.error_detail = String::from_utf8(detail_bytes).ok();
216 }
217
218 let ack = SmtpAuthData::r#continue(vec![0x01u8]);
219 self.state = State::AckError(SendSmtpCommand::new(ack));
220 continue;
221 }
222
223 let code = out.response.code.code();
224 let message = out.response.text().to_string();
225 return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
226 code,
227 message,
228 }));
229 }
230 State::AckError(send) => {
231 let _ = smtp_try!(send, arg);
232
233 let message = self
234 .error_detail
235 .take()
236 .unwrap_or_else(|| "authentication failed".into());
237
238 return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
239 code: 535,
240 message,
241 }));
242 }
243 State::Ehlo(ehlo) => {
244 let _ = smtp_try!(ehlo, arg);
245 return SmtpCoroutineState::Complete(Ok(()));
246 }
247 State::Done => return SmtpCoroutineState::Complete(Ok(())),
248 }
249 }
250 }
251}
252
253impl SmtpAuthXoauth2 {
254 fn advance_after_auth(&mut self) {
255 let _ = self.payload.take();
256 if self.opts.ensure_capabilities {
257 let domain = self.domain.take().expect("domain taken twice");
258 self.state = State::Ehlo(SmtpEhlo::new(domain));
259 } else {
260 self.state = State::Done;
261 }
262 }
263}
264
265enum State {
266 Send(SendSmtpCommand<SmtpAuthCommand<'static>>),
267 Continue(SendSmtpCommand<SmtpAuthData>),
268 AckError(SendSmtpCommand<SmtpAuthData>),
269 Ehlo(SmtpEhlo),
270 Done,
271}
272
273impl fmt::Display for State {
274 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275 match self {
276 Self::Send(_) => f.write_str("send auth xoauth2"),
277 Self::Continue(_) => f.write_str("send credentials"),
278 Self::AckError(_) => f.write_str("ack error detail"),
279 Self::Ehlo(_) => f.write_str("refresh capabilities"),
280 Self::Done => f.write_str("done"),
281 }
282 }
283}
284
285fn build_payload(username: &str, token: &SecretString) -> Vec<u8> {
288 let mut payload = Vec::new();
289 payload.extend_from_slice(b"user=");
290 payload.extend_from_slice(username.as_bytes());
291 payload.push(0x01);
292 payload.extend_from_slice(b"auth=Bearer ");
293 payload.extend_from_slice(token.expose_secret().as_bytes());
294 payload.push(0x01);
295 payload.push(0x01);
296 payload
297}
298
299#[cfg(test)]
300mod tests {
301 use crate::rfc5321::types::domain::Domain;
302
303 use super::*;
304
305 fn domain() -> EhloDomain<'static> {
306 EhloDomain::Domain(Domain(Cow::Borrowed("example.com")))
307 }
308
309 fn token() -> SecretString {
310 SecretString::from("ya29.tokenvalue".to_string())
311 }
312
313 #[test]
314 fn ir_success_then_ehlo_returns_ok() {
315 let opts = SmtpAuthXoauth2Options::default();
316 let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
317
318 let _ = expect_wants_write(&mut auth, None);
319 expect_wants_read(&mut auth);
320 let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
321 expect_wants_read(&mut auth);
322 expect_complete_ok(&mut auth, b"250 server.example.com\r\n");
323 }
324
325 #[test]
326 fn ir_success_without_ehlo_returns_ok() {
327 let opts = SmtpAuthXoauth2Options {
328 initial_request: true,
329 ensure_capabilities: false,
330 };
331 let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
332 let _ = expect_wants_write(&mut auth, None);
333 expect_wants_read(&mut auth);
334 expect_complete_ok(&mut auth, b"235 OK\r\n");
335 }
336
337 #[test]
338 fn error_detail_returns_rejected() {
339 let opts = SmtpAuthXoauth2Options {
340 initial_request: true,
341 ensure_capabilities: false,
342 };
343 let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
344 let _ = expect_wants_write(&mut auth, None);
345 expect_wants_read(&mut auth);
346
347 let challenge = b"334 eyJzdGF0dXMiOiI0MDEifQ==\r\n";
348 let _ack = expect_wants_write(&mut auth, Some(challenge));
349 expect_wants_read(&mut auth);
350
351 let err = expect_complete_err(&mut auth, b"535 authentication failed\r\n");
352 let SmtpAuthXoauth2Error::Rejected { code, message } = err else {
353 panic!("expected SmtpAuthXoauth2Error::Rejected, got {err:?}");
354 };
355 assert_eq!(code, 535);
356 assert!(message.contains("status") || message.contains("401"));
357 }
358
359 #[test]
360 fn rejected_returns_rejected_error() {
361 let opts = SmtpAuthXoauth2Options::default();
362 let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
363 let _ = expect_wants_write(&mut auth, None);
364 expect_wants_read(&mut auth);
365
366 let err = expect_complete_err(&mut auth, b"504 mechanism disabled\r\n");
367 let SmtpAuthXoauth2Error::Rejected { code, message } = err else {
368 panic!("expected SmtpAuthXoauth2Error::Rejected, got {err:?}");
369 };
370 assert_eq!(code, 504);
371 assert_eq!(message, "mechanism disabled");
372 }
373
374 #[test]
375 fn eof_returns_eof_error() {
376 let opts = SmtpAuthXoauth2Options::default();
377 let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
378 let _ = expect_wants_write(&mut auth, None);
379 expect_wants_read(&mut auth);
380
381 let err = expect_complete_err(&mut auth, b"");
382 assert!(matches!(
383 err,
384 SmtpAuthXoauth2Error::Send(SendSmtpCommandError::Eof)
385 ));
386 }
387
388 fn expect_wants_write(cor: &mut SmtpAuthXoauth2, arg: Option<&[u8]>) -> Vec<u8> {
391 match cor.resume(arg) {
392 SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
393 state => panic!("expected WantsWrite, got {state:?}"),
394 }
395 }
396
397 fn expect_wants_read(cor: &mut SmtpAuthXoauth2) {
398 match cor.resume(None) {
399 SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
400 state => panic!("expected WantsRead, got {state:?}"),
401 }
402 }
403
404 fn expect_complete_ok(cor: &mut SmtpAuthXoauth2, reply: &[u8]) {
405 match cor.resume(Some(reply)) {
406 SmtpCoroutineState::Complete(Ok(())) => {}
407 state => panic!("expected Complete(Ok), got {state:?}"),
408 }
409 }
410
411 fn expect_complete_err(cor: &mut SmtpAuthXoauth2, reply: &[u8]) -> SmtpAuthXoauth2Error {
412 match cor.resume(Some(reply)) {
413 SmtpCoroutineState::Complete(Err(err)) => err,
414 state => panic!("expected Complete(Err), got {state:?}"),
415 }
416 }
417}