1use clap::Args;
2use reqwest::StatusCode;
3
4use crate::commands::{
5 client_error::{
6 embedded_protocol_error_details, embedded_server_error_details,
7 protocol_diagnostic_details, response_error_details, server_error_details,
8 },
9 credential_ops::{
10 credential_target, lock_credential_mutation, map_credential_load_error,
11 preflight_credentials,
12 },
13};
14use crate::{
15 client::{ClientError, LogoutRequest, WekanClientFactory},
16 command_result::{
17 CancellationSuccess, CommandSuccess, DestructiveOperation, LogoutScope, LogoutSuccess,
18 },
19 credentials::{CredentialDeleteOutcome, CredentialMutation, CredentialRecord, CredentialStore},
20 error::{AppError, ErrorCode, ErrorDetails},
21 exit_code::StableExitCode,
22 input::{
23 ConfirmationArgs, ConfirmationDecision, ConfirmationProvider, ConfirmationRequest,
24 confirm_or_skip,
25 },
26 redaction::Redactor,
27};
28
29#[derive(Debug, Args)]
30pub struct LogoutArgs {
31 #[arg(long, conflicts_with = "local_only")]
33 pub all: bool,
34
35 #[arg(long, conflicts_with = "all")]
38 pub local_only: bool,
39
40 #[command(flatten)]
41 pub confirmation: ConfirmationArgs,
42}
43
44pub(crate) async fn execute(
45 args: LogoutArgs,
46 client_factory: &WekanClientFactory,
47 credential_store: &dyn CredentialStore,
48 confirmation: &dyn ConfirmationProvider,
49) -> Result<CommandSuccess, AppError> {
50 if args.local_only {
51 let server = client_factory.server_identity().as_str().to_owned();
52 let credential_target = credential_target(
53 client_factory
54 .profile()
55 .expect("authentication requires a named profile"),
56 client_factory
57 .profile_store_namespace()
58 .expect("authentication requires a profile credential namespace"),
59 server.clone(),
60 );
61 let credential_mutation = lock_credential_mutation(credential_store, &credential_target)
62 .map_err(|error| {
63 error.with_details(no_remote_mutation_details(LogoutScope::LocalOnly, None))
64 })?;
65 if let Some(cancelled) = confirm_logout(
66 &args,
67 client_factory,
68 &server,
69 LogoutScope::LocalOnly,
70 confirmation,
71 )? {
72 return Ok(cancelled);
73 }
74 preflight_credentials(credential_store, &credential_target).map_err(|error| {
75 error.with_details(no_remote_mutation_details(LogoutScope::LocalOnly, None))
76 })?;
77 return execute_local_only(client_factory, credential_mutation.as_ref());
78 }
79
80 let scope = if args.all {
81 LogoutScope::AllTokens
82 } else {
83 LogoutScope::CurrentToken
84 };
85 let client = client_factory.create().map_err(|error| {
86 AppError::from(error).with_details(no_remote_mutation_details(scope, None))
87 })?;
88 let server = client.server().as_str().to_owned();
89 let credential_target = credential_target(
90 client_factory
91 .profile()
92 .expect("authentication requires a named profile"),
93 client_factory
94 .profile_store_namespace()
95 .expect("authentication requires a profile credential namespace"),
96 server.clone(),
97 );
98 let credential_mutation = lock_credential_mutation(credential_store, &credential_target)
99 .map_err(|error| error.with_details(no_remote_mutation_details(scope, None)))?;
100 if let Some(cancelled) = confirm_logout(&args, client_factory, &server, scope, confirmation)? {
101 return Ok(cancelled);
102 }
103 preflight_credentials(credential_store, &credential_target)
104 .map_err(|error| error.with_details(no_remote_mutation_details(scope, None)))?;
105 let record = credential_mutation
106 .load()
107 .map_err(|error| {
108 let credential_stored = matches!(
109 &error,
110 crate::credentials::CredentialError::Deserialize(_)
111 | crate::credentials::CredentialError::UnsupportedVersion(_)
112 | crate::credentials::CredentialError::InvalidRecord(_)
113 | crate::credentials::CredentialError::TimestampParse(_)
114 )
115 .then_some(true);
116 map_credential_load_error(error)
117 .with_details(no_remote_mutation_details(scope, credential_stored))
118 })?
119 .ok_or_else(|| credential_not_found(scope))?;
120 let redactor = Redactor::with_secret(record.token());
121
122 client
123 .logout(&LogoutRequest { all: args.all }, record.token())
124 .await
125 .map_err(|error| map_client_error(error, &redactor, scope))?;
126
127 let deletion = delete_completed_logout_credential(
128 credential_mutation.as_ref(),
129 &record,
130 &redactor,
131 scope,
132 None,
133 )?;
134
135 Ok(success(
136 server,
137 client_factory
138 .profile()
139 .expect("authentication requires a named profile")
140 .to_owned(),
141 scope,
142 true,
143 deletion,
144 ))
145}
146
147fn confirm_logout(
148 args: &LogoutArgs,
149 client_factory: &WekanClientFactory,
150 server: &str,
151 scope: LogoutScope,
152 confirmation: &dyn ConfirmationProvider,
153) -> Result<Option<CommandSuccess>, AppError> {
154 let profile = client_factory
155 .profile()
156 .expect("authentication requires a named profile");
157 let target = format!("profile `{profile}` on {server}");
158 let prompt = match scope {
159 LogoutScope::CurrentToken => format!(
160 "Revoke the current Wekan login token and remove its local credential for {target}?"
161 ),
162 LogoutScope::AllTokens => format!(
163 "Revoke all Wekan login tokens for {target}, including browser and other CLI sessions, and remove the local credential?"
164 ),
165 LogoutScope::LocalOnly => format!(
166 "Remove the local credential for {target} without revoking its Wekan login token?"
167 ),
168 };
169 let request = ConfirmationRequest::new(DestructiveOperation::AuthLogout, prompt);
170 match confirm_or_skip(&args.confirmation, confirmation, &request)? {
171 ConfirmationDecision::Proceed => Ok(None),
172 ConfirmationDecision::Cancelled => Ok(Some(CommandSuccess::Cancelled(
173 CancellationSuccess::new(DestructiveOperation::AuthLogout),
174 ))),
175 }
176}
177
178fn delete_completed_logout_credential(
179 credential_mutation: &dyn CredentialMutation,
180 record: &CredentialRecord,
181 redactor: &Redactor<'_>,
182 scope: LogoutScope,
183 http_status: Option<u16>,
184) -> Result<CredentialDeleteOutcome, AppError> {
185 credential_mutation
186 .delete_if_matches(record)
187 .map_err(|error| {
188 AppError::new(
189 ErrorCode::CredentialStoreFailed,
190 redactor.redact(&format!(
191 "Wekan completed logout, but the stored credential could not be removed: {error}"
192 )),
193 StableExitCode::Credential,
194 )
195 .with_details(ErrorDetails {
196 http_status,
197 logout_scope: Some(scope),
198 remote_logout_completed: Some(true),
199 ..ErrorDetails::default()
200 })
201 })
202}
203
204fn execute_local_only(
205 client_factory: &WekanClientFactory,
206 credential_mutation: &dyn CredentialMutation,
207) -> Result<CommandSuccess, AppError> {
208 let server = client_factory.server_identity().as_str().to_owned();
209 let removed = credential_mutation.delete().map_err(|error| {
210 AppError::new(
211 ErrorCode::CredentialStoreFailed,
212 format!("the stored credential could not be removed: {error}"),
213 StableExitCode::Credential,
214 )
215 .with_details(ErrorDetails {
216 logout_scope: Some(LogoutScope::LocalOnly),
217 remote_logout_completed: Some(false),
218 ..ErrorDetails::default()
219 })
220 })?;
221
222 Ok(success(
223 server,
224 client_factory
225 .profile()
226 .expect("authentication requires a named profile")
227 .to_owned(),
228 LogoutScope::LocalOnly,
229 false,
230 if removed {
231 CredentialDeleteOutcome::Removed
232 } else {
233 CredentialDeleteOutcome::Absent
234 },
235 ))
236}
237
238fn success(
239 server: String,
240 profile: String,
241 logout_scope: LogoutScope,
242 remote: bool,
243 deletion: CredentialDeleteOutcome,
244) -> CommandSuccess {
245 CommandSuccess::Logout(LogoutSuccess {
246 server,
247 profile,
248 logout_scope,
249 remote_logout_completed: remote,
250 credential_stored: deletion == CredentialDeleteOutcome::Mismatch,
251 local_credential_removed: deletion == CredentialDeleteOutcome::Removed,
252 })
253}
254
255fn credential_not_found(scope: LogoutScope) -> AppError {
256 AppError::new(
257 ErrorCode::CredentialNotFound,
258 "no credential is stored for this Wekan server; run `wekan auth login` or `wekan auth register`",
259 StableExitCode::Server,
260 )
261 .with_details(no_remote_mutation_details(scope, Some(false)))
262}
263
264fn no_remote_mutation_details(scope: LogoutScope, credential_stored: Option<bool>) -> ErrorDetails {
265 ErrorDetails {
266 logout_scope: Some(scope),
267 remote_logout_completed: Some(false),
268 credential_stored,
269 local_credential_removed: Some(false),
270 ..ErrorDetails::default()
271 }
272}
273
274fn map_client_error(error: ClientError, redactor: &Redactor<'_>, scope: LogoutScope) -> AppError {
275 match error {
276 error @ ClientError::EmptyNotFound { .. } => {
277 crate::commands::client_error::map_client_error(error, redactor, "logout")
278 }
279 ClientError::Build(_) => AppError::new(
280 ErrorCode::InternalError,
281 "the HTTP client could not be initialized",
282 StableExitCode::Internal,
283 )
284 .with_details(no_remote_mutation_details(scope, Some(true))),
285 ClientError::Transport(error) => AppError::new(
286 ErrorCode::TransportError,
287 redactor.redact(&format!(
288 "the logout request could not be completed: {error}"
289 )),
290 StableExitCode::Transport,
291 )
292 .with_details(ErrorDetails {
293 logout_scope: Some(scope),
294
295 credential_stored: Some(true),
296 local_credential_removed: Some(false),
297 ..ErrorDetails::default()
298 }),
299 ClientError::UnexpectedRedirect { status } => AppError::new(
300 ErrorCode::UnexpectedRedirect,
301 "the Wekan server returned a redirect; logout requests are not replayed",
302 StableExitCode::Transport,
303 )
304 .with_details(ErrorDetails {
305 http_status: Some(status.as_u16()),
306 logout_scope: Some(scope),
307
308 credential_stored: Some(true),
309 local_credential_removed: Some(false),
310 ..ErrorDetails::default()
311 }),
312 ClientError::ResponseTooLarge {
313 limit_bytes,
314 status,
315 retry_after_seconds,
316 } => response_body_error(
317 format!("the server response exceeded the {limit_bytes}-byte safety limit"),
318 status,
319 retry_after_seconds,
320 scope,
321 ),
322 ClientError::ResponseBody {
323 status,
324 retry_after_seconds,
325 ..
326 } => response_body_error(
327 "the server response body could not be read".to_owned(),
328 status,
329 retry_after_seconds,
330 scope,
331 ),
332 ClientError::Protocol {
333 diagnostic,
334 message,
335 success_status_received,
336 } => {
337 let mut details =
338 protocol_diagnostic_details(success_status_received, diagnostic, redactor);
339 details.logout_scope = Some(scope);
340 details.remote_logout_completed = if success_status_received {
341 None
342 } else {
343 Some(false)
344 };
345
346 details.credential_stored = Some(true);
347 details.local_credential_removed = Some(false);
348 AppError::new(
349 ErrorCode::ProtocolError,
350 redactor.redact(&format!("invalid response from Wekan: {message}")),
351 StableExitCode::Transport,
352 )
353 .with_details(details)
354 }
355 ClientError::EmbeddedProtocol {
356 http_status,
357 server_error,
358 server_reason,
359 server_message,
360 server_error_type,
361 server_is_client_safe,
362 } => {
363 let mut details = embedded_protocol_error_details(
364 http_status,
365 server_error,
366 server_reason,
367 server_message,
368 server_error_type,
369 server_is_client_safe,
370 redactor,
371 );
372 details.logout_scope = Some(scope);
373
374 details.credential_stored = Some(true);
375 details.local_credential_removed = Some(false);
376 AppError::new(
377 ErrorCode::ProtocolError,
378 "Wekan returned an embedded logout error without statusCode",
379 StableExitCode::Server,
380 )
381 .with_details(details)
382 }
383 ClientError::Server {
384 status,
385 server_error,
386 server_reason,
387 retry_after_seconds,
388 } => {
389 let authentication_rejected = status == StatusCode::UNAUTHORIZED;
390 let mut details = server_error_details(
391 status,
392 server_error,
393 server_reason,
394 retry_after_seconds,
395 redactor,
396 );
397 details.logout_scope = Some(scope);
398
399 details.remote_logout_completed = if status.is_server_error() {
400 None
401 } else {
402 Some(false)
403 };
404 details.credential_stored = Some(true);
405 details.local_credential_removed = Some(false);
406 AppError::new(
407 if authentication_rejected {
408 ErrorCode::AuthenticationRejected
409 } else {
410 ErrorCode::ServerError
411 },
412 if authentication_rejected {
413 "the stored credential was rejected by Wekan and was preserved locally"
414 } else if status.is_server_error() {
415 "the Wekan server returned an unexpected logout error"
416 } else {
417 "the Wekan server rejected logout and the stored credential was preserved"
418 },
419 StableExitCode::Server,
420 )
421 .with_details(details)
422 }
423 ClientError::EmbeddedServer {
424 http_status,
425 wekan_status_code,
426 server_error,
427 server_reason,
428 } => {
429 let mut details = embedded_server_error_details(
430 http_status,
431 wekan_status_code,
432 server_error,
433 server_reason,
434 redactor,
435 );
436 details.logout_scope = Some(scope);
437 details.remote_logout_completed = Some(false);
438 details.credential_stored = Some(true);
439 details.local_credential_removed = Some(false);
440 AppError::new(
441 ErrorCode::ServerError,
442 "the Wekan server returned an unexpected embedded logout error",
443 StableExitCode::Server,
444 )
445 .with_details(details)
446 }
447 }
448}
449
450fn response_body_error(
451 message: String,
452 status: StatusCode,
453 retry_after_seconds: Option<u64>,
454 scope: LogoutScope,
455) -> AppError {
456 let mut details = response_error_details(status, retry_after_seconds);
457 details.logout_scope = Some(scope);
458 details.credential_stored = Some(true);
459 details.local_credential_removed = Some(false);
460 if status == StatusCode::OK {
461 return AppError::new(ErrorCode::ProtocolError, message, StableExitCode::Transport)
462 .with_details(details);
463 }
464
465 let authentication_rejected = status == StatusCode::UNAUTHORIZED;
466
467 details.remote_logout_completed = if status.is_server_error() {
468 None
469 } else {
470 Some(false)
471 };
472 AppError::new(
473 if authentication_rejected {
474 ErrorCode::AuthenticationRejected
475 } else {
476 ErrorCode::ServerError
477 },
478 if authentication_rejected {
479 "the stored credential was rejected by Wekan and was preserved locally".to_owned()
480 } else {
481 message
482 },
483 StableExitCode::Server,
484 )
485 .with_details(details)
486}
487
488#[cfg(test)]
489mod tests;