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(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(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(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 ClientError::Build(_) => AppError::new(
277 ErrorCode::InternalError,
278 "the HTTP client could not be initialized",
279 StableExitCode::Internal,
280 )
281 .with_details(no_remote_mutation_details(scope, Some(true))),
282 ClientError::Transport(error) => AppError::new(
283 ErrorCode::TransportError,
284 redactor.redact(&format!(
285 "the logout request could not be completed: {error}; its remote outcome may be unknown"
286 )),
287 StableExitCode::Transport,
288 )
289 .with_details(ErrorDetails {
290 logout_scope: Some(scope),
291 remote_logout_completed: Some(None),
292 outcome_unknown: Some(true),
293 credential_stored: Some(true),
294 local_credential_removed: Some(false),
295 ..ErrorDetails::default()
296 }),
297 ClientError::UnexpectedRedirect { status } => AppError::new(
298 ErrorCode::UnexpectedRedirect,
299 "the Wekan server returned a redirect; logout requests are not replayed and the remote outcome may be unknown",
300 StableExitCode::Transport,
301 )
302 .with_details(ErrorDetails {
303 http_status: Some(status.as_u16()),
304 logout_scope: Some(scope),
305 remote_logout_completed: Some(None),
306 outcome_unknown: Some(true),
307 credential_stored: Some(true),
308 local_credential_removed: Some(false),
309 ..ErrorDetails::default()
310 }),
311 ClientError::ResponseTooLarge {
312 limit_bytes,
313 status,
314 retry_after_seconds,
315 } => response_body_error(
316 format!("the server response exceeded the {limit_bytes}-byte safety limit"),
317 status,
318 retry_after_seconds,
319 scope,
320 ),
321 ClientError::ResponseBody {
322 status,
323 retry_after_seconds,
324 ..
325 } => response_body_error(
326 "the server response body could not be read".to_owned(),
327 status,
328 retry_after_seconds,
329 scope,
330 ),
331 ClientError::Protocol {
332 diagnostic,
333 message,
334 success_status_received,
335 } => {
336 let mut details = protocol_diagnostic_details(success_status_received, diagnostic, redactor);
337 details.logout_scope = Some(scope);
338 details.remote_logout_completed = Some(if success_status_received {
339 None
340 } else {
341 Some(false)
342 });
343 details.outcome_unknown = success_status_received.then_some(true);
344 details.credential_stored = Some(true);
345 details.local_credential_removed = Some(false);
346 AppError::new(
347 ErrorCode::ProtocolError,
348 redactor.redact(&format!("invalid response from Wekan: {message}")),
349 StableExitCode::Transport,
350 )
351 .with_details(details)
352 }
353 ClientError::EmbeddedProtocol {
354 http_status,
355 server_error,
356 server_reason,
357 server_message,
358 server_error_type,
359 server_is_client_safe,
360 } => {
361 let mut details = embedded_protocol_error_details(
362 http_status,
363 server_error,
364 server_reason,
365 server_message,
366 server_error_type,
367 server_is_client_safe,
368 redactor,
369 );
370 details.logout_scope = Some(scope);
371 details.remote_logout_completed = Some(None);
372 details.outcome_unknown = Some(true);
373 details.credential_stored = Some(true);
374 details.local_credential_removed = Some(false);
375 AppError::new(
376 ErrorCode::ProtocolError,
377 "Wekan returned an embedded logout error without statusCode; the remote outcome may be unknown and the stored credential was preserved",
378 StableExitCode::Server,
379 )
380 .with_details(details)
381 }
382 ClientError::Server {
383 status,
384 server_error,
385 server_reason,
386 retry_after_seconds,
387 } => {
388 let authentication_rejected = status == StatusCode::UNAUTHORIZED;
389 let mut details = server_error_details(
390 status,
391 server_error,
392 server_reason,
393 retry_after_seconds,
394 redactor,
395 );
396 details.logout_scope = Some(scope);
397 details.outcome_unknown = status.is_server_error().then_some(true);
398 details.remote_logout_completed = Some(if status.is_server_error() {
399 None
400 } else {
401 Some(false)
402 });
403 details.credential_stored = Some(true);
404 details.local_credential_removed = Some(false);
405 AppError::new(
406 if authentication_rejected {
407 ErrorCode::AuthenticationRejected
408 } else {
409 ErrorCode::ServerError
410 },
411 if authentication_rejected {
412 "the stored credential was rejected by Wekan and was preserved locally"
413 } else if status.is_server_error() {
414 "the Wekan server returned an unexpected logout error; the remote outcome may be unknown and the stored credential was preserved"
415 } else {
416 "the Wekan server rejected logout and the stored credential was preserved"
417 },
418 StableExitCode::Server,
419 )
420 .with_details(details)
421 }
422 ClientError::EmbeddedServer {
423 http_status,
424 wekan_status_code,
425 server_error,
426 server_reason,
427 } => {
428 let mut details = embedded_server_error_details(
429 http_status,
430 wekan_status_code,
431 server_error,
432 server_reason,
433 redactor,
434 );
435 details.logout_scope = Some(scope);
436 details.remote_logout_completed = Some(Some(false));
437 details.credential_stored = Some(true);
438 details.local_credential_removed = Some(false);
439 AppError::new(
440 ErrorCode::ServerError,
441 "the Wekan server returned an unexpected embedded logout error",
442 StableExitCode::Server,
443 )
444 .with_details(details)
445 }
446 }
447}
448
449fn response_body_error(
450 message: String,
451 status: StatusCode,
452 retry_after_seconds: Option<u64>,
453 scope: LogoutScope,
454) -> AppError {
455 let mut details = response_error_details(status, retry_after_seconds);
456 details.logout_scope = Some(scope);
457 details.credential_stored = Some(true);
458 details.local_credential_removed = Some(false);
459 if status == StatusCode::OK {
460 details.remote_logout_completed = Some(None);
461 details.outcome_unknown = Some(true);
462 return AppError::new(ErrorCode::ProtocolError, message, StableExitCode::Transport)
463 .with_details(details);
464 }
465
466 let authentication_rejected = status == StatusCode::UNAUTHORIZED;
467 details.outcome_unknown = status.is_server_error().then_some(true);
468 details.remote_logout_completed = Some(if status.is_server_error() {
469 None
470 } else {
471 Some(false)
472 });
473 AppError::new(
474 if authentication_rejected {
475 ErrorCode::AuthenticationRejected
476 } else {
477 ErrorCode::ServerError
478 },
479 if authentication_rejected {
480 "the stored credential was rejected by Wekan and was preserved locally".to_owned()
481 } else if status.is_server_error() {
482 format!("{message}; the remote outcome may be unknown and the stored credential was preserved")
483 } else {
484 message
485 },
486 StableExitCode::Server,
487 )
488 .with_details(details)
489}
490
491#[cfg(test)]
492mod tests;