Skip to main content

wekan_cli/commands/auth/
logout.rs

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    /// Revoke every login token for the authenticated Wekan user.
32    #[arg(long, conflicts_with = "local_only")]
33    pub all: bool,
34
35    /// Remove only the local credential without contacting Wekan. With --server,
36    /// this can also clear an orphaned credential for a missing profile.
37    #[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;