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(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;