1use clap::Args;
2use reqwest::StatusCode;
3
4use crate::commands::{
5 client_error::{
6 embedded_protocol_error_details, embedded_server_error_details, protocol_error_details,
7 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 message,
333 success_status_received,
334 } => {
335 let mut details = protocol_error_details(success_status_received);
336 details.logout_scope = Some(scope);
337 details.remote_logout_completed = Some(if success_status_received {
338 None
339 } else {
340 Some(false)
341 });
342 details.outcome_unknown = success_status_received.then_some(true);
343 details.credential_stored = Some(true);
344 details.local_credential_removed = Some(false);
345 AppError::new(
346 ErrorCode::ProtocolError,
347 redactor.redact(&format!("invalid response from Wekan: {message}")),
348 StableExitCode::Transport,
349 )
350 .with_details(details)
351 }
352 ClientError::EmbeddedProtocol {
353 http_status,
354 server_error,
355 server_reason,
356 server_message,
357 server_error_type,
358 server_is_client_safe,
359 } => {
360 let mut details = embedded_protocol_error_details(
361 http_status,
362 server_error,
363 server_reason,
364 server_message,
365 server_error_type,
366 server_is_client_safe,
367 redactor,
368 );
369 details.logout_scope = Some(scope);
370 details.remote_logout_completed = Some(None);
371 details.outcome_unknown = Some(true);
372 details.credential_stored = Some(true);
373 details.local_credential_removed = Some(false);
374 AppError::new(
375 ErrorCode::ProtocolError,
376 "Wekan returned an embedded logout error without statusCode; the remote outcome may be unknown and the stored credential was preserved",
377 StableExitCode::Server,
378 )
379 .with_details(details)
380 }
381 ClientError::Server {
382 status,
383 server_error,
384 server_reason,
385 retry_after_seconds,
386 } => {
387 let authentication_rejected = status == StatusCode::UNAUTHORIZED;
388 let mut details = server_error_details(
389 status,
390 server_error,
391 server_reason,
392 retry_after_seconds,
393 redactor,
394 );
395 details.logout_scope = Some(scope);
396 details.outcome_unknown = status.is_server_error().then_some(true);
397 details.remote_logout_completed = Some(if status.is_server_error() {
398 None
399 } else {
400 Some(false)
401 });
402 details.credential_stored = Some(true);
403 details.local_credential_removed = Some(false);
404 AppError::new(
405 if authentication_rejected {
406 ErrorCode::AuthenticationRejected
407 } else {
408 ErrorCode::ServerError
409 },
410 if authentication_rejected {
411 "the stored credential was rejected by Wekan and was preserved locally"
412 } else if status.is_server_error() {
413 "the Wekan server returned an unexpected logout error; the remote outcome may be unknown and the stored credential was preserved"
414 } else {
415 "the Wekan server rejected logout and the stored credential was preserved"
416 },
417 StableExitCode::Server,
418 )
419 .with_details(details)
420 }
421 ClientError::EmbeddedServer {
422 http_status,
423 wekan_status_code,
424 server_error,
425 server_reason,
426 } => {
427 let mut details = embedded_server_error_details(
428 http_status,
429 wekan_status_code,
430 server_error,
431 server_reason,
432 redactor,
433 );
434 details.logout_scope = Some(scope);
435 details.remote_logout_completed = Some(Some(false));
436 details.credential_stored = Some(true);
437 details.local_credential_removed = Some(false);
438 AppError::new(
439 ErrorCode::ServerError,
440 "the Wekan server returned an unexpected embedded logout error",
441 StableExitCode::Server,
442 )
443 .with_details(details)
444 }
445 }
446}
447
448fn response_body_error(
449 message: String,
450 status: StatusCode,
451 retry_after_seconds: Option<u64>,
452 scope: LogoutScope,
453) -> AppError {
454 let mut details = response_error_details(status, retry_after_seconds);
455 details.logout_scope = Some(scope);
456 details.credential_stored = Some(true);
457 details.local_credential_removed = Some(false);
458 if status == StatusCode::OK {
459 details.remote_logout_completed = Some(None);
460 details.outcome_unknown = Some(true);
461 return AppError::new(ErrorCode::ProtocolError, message, StableExitCode::Transport)
462 .with_details(details);
463 }
464
465 let authentication_rejected = status == StatusCode::UNAUTHORIZED;
466 details.outcome_unknown = status.is_server_error().then_some(true);
467 details.remote_logout_completed = Some(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 if status.is_server_error() {
481 format!("{message}; the remote outcome may be unknown and the stored credential was preserved")
482 } else {
483 message
484 },
485 StableExitCode::Server,
486 )
487 .with_details(details)
488}
489
490#[cfg(test)]
491mod tests;