Skip to main content

wekan_cli/commands/
auth.rs

1pub mod login;
2pub mod logout;
3pub mod register;
4pub mod status;
5
6use clap::{Args, Subcommand};
7use time::format_description::well_known::Rfc3339;
8
9use crate::{
10    client::AuthSession,
11    command_result::{AuthSuccess, CommandSuccess},
12    config::{MissingProfileResolution, ResolvedTarget},
13    credentials::{
14        CredentialCreateOutcome, CredentialMutation, CredentialRecord, CredentialStore,
15        SecretInputProvider,
16    },
17    error::{AppError, ErrorCode},
18    exit_code::StableExitCode,
19    input::ConfirmationProvider,
20    redaction::Redactor,
21};
22
23use super::credential_ops::map_credential_load_error;
24
25#[derive(Debug, Args)]
26pub struct AuthArgs {
27    #[command(subcommand)]
28    pub command: AuthCommand,
29}
30
31#[derive(Debug, Subcommand)]
32pub enum AuthCommand {
33    /// Log in to Wekan and securely store the returned token.
34    Login(login::LoginArgs),
35
36    /// Revoke Wekan login tokens and remove the stored credential.
37    Logout(logout::LogoutArgs),
38
39    /// Register a Wekan account and securely store its login token.
40    Register(register::RegisterArgs),
41
42    /// Validate and display the stored authentication session.
43    Status(status::StatusArgs),
44}
45
46impl AuthCommand {
47    pub(crate) const fn missing_profile_resolution(&self) -> MissingProfileResolution {
48        match self {
49            Self::Login(_) | Self::Register(_) => MissingProfileResolution::Initialize,
50            Self::Logout(args) if args.local_only => {
51                MissingProfileResolution::LocalCredentialCleanup
52            }
53            Self::Logout(_) | Self::Status(_) => MissingProfileResolution::Reject,
54        }
55    }
56}
57
58pub(crate) async fn dispatch(
59    command: AuthCommand,
60    target: &mut ResolvedTarget<'_>,
61    credential_store: &dyn CredentialStore,
62    secret_input: &dyn SecretInputProvider,
63    confirmation: &dyn ConfirmationProvider,
64) -> Result<CommandSuccess, AppError> {
65    match command {
66        AuthCommand::Login(args) => {
67            login::execute(args, target, credential_store, secret_input).await
68        }
69        AuthCommand::Register(args) => {
70            register::execute(args, target, credential_store, secret_input).await
71        }
72        AuthCommand::Logout(args) => {
73            logout::execute(
74                args,
75                target.client_factory(),
76                credential_store,
77                confirmation,
78            )
79            .await
80        }
81        AuthCommand::Status(args) => {
82            status::execute(args, target.client_factory(), credential_store).await
83        }
84    }
85}
86
87pub(super) fn persist_session(
88    target: &mut ResolvedTarget<'_>,
89    credential_mutation: &dyn CredentialMutation,
90    server_url: String,
91    session: AuthSession,
92) -> Result<AuthSuccess, AppError> {
93    let (user_id, token, token_expires) = session.into_parts();
94    let token_expires_text = token_expires.format(&Rfc3339).map_err(|_| {
95        AppError::new(
96            ErrorCode::InternalError,
97            "the validated token expiry could not be formatted",
98            StableExitCode::Internal,
99        )
100    })?;
101    let record = CredentialRecord::new(server_url.clone(), user_id.clone(), token, token_expires);
102    let token_redactor = Redactor::with_secret(record.token());
103
104    target.initialize_profile()?;
105    let profile_created = target.profile_created();
106    let profile_active = target.profile_active();
107    match credential_mutation.create(&record).map_err(|error| {
108        AppError::new(
109            ErrorCode::CredentialStoreFailed,
110            token_redactor.redact(&error.to_string()),
111            StableExitCode::Credential,
112        )
113        .with_profile_state(profile_created, profile_active)
114    })? {
115        CredentialCreateOutcome::Created => {}
116        CredentialCreateOutcome::AlreadyExists => {
117            return Err(AppError::new(
118                ErrorCode::CredentialAlreadyExists,
119                format!(
120                    "profile `{}` acquired a stored credential concurrently; the existing entry was preserved, and the remote session or account may already have been created; inspect Wekan, then run `wekan --server {} --profile {} auth logout --local-only --yes` before authenticating again",
121                    target.profile(),
122                    server_url,
123                    target.profile()
124                ),
125                StableExitCode::Configuration,
126            )
127            .with_credential_stored(true)
128            .with_profile_state(profile_created, profile_active));
129        }
130    }
131
132    Ok(AuthSuccess {
133        server: server_url,
134        profile: target.profile().to_owned(),
135        user_id,
136        token_expires: token_expires_text,
137        credential_stored: true,
138        profile_created,
139        profile_active,
140    })
141}
142
143pub(super) fn ensure_credential_absent(
144    credential_mutation: &dyn CredentialMutation,
145    target: &ResolvedTarget<'_>,
146) -> Result<(), AppError> {
147    let exists = credential_mutation
148        .exists()
149        .map_err(map_credential_load_error)?;
150    if exists {
151        Err(credential_already_exists(target))
152    } else {
153        Ok(())
154    }
155}
156
157fn credential_already_exists(target: &ResolvedTarget<'_>) -> AppError {
158    AppError::new(
159        ErrorCode::CredentialAlreadyExists,
160        format!(
161            "profile `{}` already has a stored credential; run `wekan --server {} --profile {} auth logout --local-only --yes` before authenticating again",
162            target.profile(),
163            target.client_factory().server_identity().as_str(),
164            target.profile()
165        ),
166        StableExitCode::Configuration,
167    )
168    .with_credential_stored(true)
169    .with_profile_state(target.profile_created(), target.profile_active())
170}
171
172pub(super) fn non_empty_identity(value: &str) -> Result<String, String> {
173    if value.is_empty() {
174        Err("value must not be empty".to_owned())
175    } else {
176        Ok(value.to_owned())
177    }
178}