Skip to main content

wekan_cli/commands/
auth.rs

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