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 Login(login::LoginArgs),
35
36 Logout(logout::LogoutArgs),
38
39 Register(register::RegisterArgs),
41
42 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}