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