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, WekanClientFactory},
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) enum PreparedAuthCommand<'command, 'store> {
59    Login {
60        args: login::LoginArgs,
61        target: &'command mut ResolvedTarget<'store>,
62    },
63    Logout {
64        args: logout::LogoutArgs,
65        client_factory: &'command WekanClientFactory,
66    },
67    Register {
68        args: register::RegisterArgs,
69        target: &'command mut ResolvedTarget<'store>,
70    },
71    Status {
72        args: status::StatusArgs,
73        client_factory: &'command WekanClientFactory,
74    },
75}
76
77pub(crate) async fn dispatch(
78    command: PreparedAuthCommand<'_, '_>,
79    credential_store: &dyn CredentialStore,
80    secret_input: &dyn SecretInputProvider,
81    confirmation: &dyn ConfirmationProvider,
82) -> Result<CommandSuccess, AppError> {
83    match command {
84        PreparedAuthCommand::Login { args, target } => {
85            login::execute(args, target, credential_store, secret_input).await
86        }
87        PreparedAuthCommand::Logout {
88            args,
89            client_factory,
90        } => logout::execute(args, client_factory, credential_store, confirmation).await,
91        PreparedAuthCommand::Register { args, target } => {
92            register::execute(args, target, credential_store, secret_input).await
93        }
94        PreparedAuthCommand::Status {
95            args,
96            client_factory,
97        } => status::execute(args, client_factory, credential_store).await,
98    }
99}
100
101pub(super) fn persist_session(
102    target: &mut ResolvedTarget<'_>,
103    credential_mutation: &dyn CredentialMutation,
104    server_url: String,
105    session: AuthSession,
106) -> Result<AuthSuccess, AppError> {
107    let (user_id, token, token_expires) = session.into_parts();
108    let token_expires_text = token_expires.format(&Rfc3339).map_err(|_| {
109        AppError::new(
110            ErrorCode::InternalError,
111            "the validated token expiry could not be formatted",
112            StableExitCode::Internal,
113        )
114    })?;
115    let record = CredentialRecord::new(server_url.clone(), user_id.clone(), token, token_expires);
116    let token_redactor = Redactor::with_secret(record.token());
117
118    target.initialize_profile()?;
119    let profile_created = target.profile_created();
120    let profile_active = target.profile_active();
121    match credential_mutation.create(&record).map_err(|error| {
122        AppError::new(
123            ErrorCode::CredentialStoreFailed,
124            token_redactor.redact(&error.to_string()),
125            StableExitCode::Credential,
126        )
127        .with_profile_state(profile_created, profile_active)
128    })? {
129        CredentialCreateOutcome::Created => {}
130        CredentialCreateOutcome::AlreadyExists => {
131            return Err(AppError::new(
132                ErrorCode::CredentialAlreadyExists,
133                format!(
134                    "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",
135                    target.profile(),
136                    server_url,
137                    target.profile()
138                ),
139                StableExitCode::Configuration,
140            )
141            .with_credential_stored(true)
142            .with_profile_state(profile_created, profile_active));
143        }
144    }
145
146    Ok(AuthSuccess {
147        server: server_url,
148        profile: target.profile().to_owned(),
149        user_id,
150        token_expires: token_expires_text,
151        credential_stored: true,
152        profile_created,
153        profile_active,
154    })
155}
156
157pub(super) fn ensure_credential_absent(
158    credential_mutation: &dyn CredentialMutation,
159    target: &ResolvedTarget<'_>,
160) -> Result<(), AppError> {
161    let exists = credential_mutation
162        .exists()
163        .map_err(map_credential_load_error)?;
164    if exists {
165        Err(credential_already_exists(target))
166    } else {
167        Ok(())
168    }
169}
170
171fn credential_already_exists(target: &ResolvedTarget<'_>) -> AppError {
172    AppError::new(
173        ErrorCode::CredentialAlreadyExists,
174        format!(
175            "profile `{}` already has a stored credential; run `wekan --server {} --profile {} auth logout --local-only --yes` before authenticating again",
176            target.profile(),
177            target.client_factory().server_identity().as_str(),
178            target.profile()
179        ),
180        StableExitCode::Configuration,
181    )
182    .with_credential_stored(true)
183    .with_profile_state(target.profile_created(), target.profile_active())
184}
185
186pub(super) fn non_empty_identity(value: &str) -> Result<String, String> {
187    if value.is_empty() {
188        Err("value must not be empty".to_owned())
189    } else {
190        Ok(value.to_owned())
191    }
192}