1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
18use chrono::Utc;
19use serde::Serialize;
20use serde_json::json;
21use sha2::{Digest, Sha256};
22use sqlx::PgPool;
23use uuid::Uuid;
24
25use crate::{
26 auth::{self, Actor},
27 domain,
28 error::{AppError, AppResult},
29 services::{audit, users},
30};
31
32const CODE_TTL_MINUTES: i64 = 5;
34pub const TOKEN_DAYS: i64 = 30;
40const CODE_PREFIX: &str = "tc_dsk_";
41
42#[derive(Clone, Debug, Serialize)]
44pub struct AuthorizeRequest {
45 pub port: u16,
47 pub state: String,
48 pub challenge: String,
49 pub device: String,
51}
52
53impl AuthorizeRequest {
54 pub fn parse(port: &str, state: &str, challenge: &str, device: &str) -> AppResult<Self> {
55 let port: u16 = port
56 .trim()
57 .parse()
58 .ok()
59 .filter(|p| *p >= 1024)
60 .ok_or_else(|| AppError::BadRequest("Invalid port of the desktop app.".into()))?;
61 let token_chars = |s: &str| {
62 s.chars()
63 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
64 };
65 let state = state.trim();
66 if !(16..=128).contains(&state.len()) || !token_chars(state) {
67 return Err(AppError::BadRequest(
68 "Invalid state of the desktop sign-in.".into(),
69 ));
70 }
71 let challenge = challenge.trim();
73 if challenge.len() != 43 || !token_chars(challenge) {
74 return Err(AppError::BadRequest(
75 "Invalid challenge of the desktop sign-in.".into(),
76 ));
77 }
78 let device: String = device
79 .chars()
80 .filter(|c| !c.is_control())
81 .collect::<String>()
82 .trim()
83 .chars()
84 .take(64)
85 .collect();
86 let device = if device.is_empty() {
87 "unknown computer".to_string()
88 } else {
89 device
90 };
91 Ok(AuthorizeRequest {
92 port,
93 state: state.to_string(),
94 challenge: challenge.to_string(),
95 device,
96 })
97 }
98
99 pub fn callback(&self, query: &str) -> String {
102 format!("http://127.0.0.1:{}/callback?{query}", self.port)
103 }
104}
105
106pub fn challenge_of(verifier: &str) -> String {
108 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
109}
110
111pub async fn approve(pool: &PgPool, actor: &Actor, req: &AuthorizeRequest) -> AppResult<String> {
125 let code = auth::generate_token(CODE_PREFIX);
126 let mut tx = pool.begin().await?;
127 sqlx::query(
128 "INSERT INTO desktop_auth_codes (code_hash, user_id, challenge, device, expires_at)
129 VALUES ($1, $2, $3, $4, $5)",
130 )
131 .bind(auth::hash_token(&code))
132 .bind(actor.id)
133 .bind(&req.challenge)
134 .bind(&req.device)
135 .bind(Utc::now() + chrono::Duration::minutes(CODE_TTL_MINUTES))
136 .execute(&mut *tx)
137 .await?;
138 audit::record(
139 &mut tx,
140 Some(actor),
141 "desktop.authorize",
142 "user",
143 Some(actor.id),
144 format!("Allowed TeamCraft Desktop on {}", req.device),
145 json!({ "device": req.device }),
146 )
147 .await?;
148 tx.commit().await?;
149 Ok(req.callback(&format!(
150 "code={}&state={}",
151 crate::util::pct_encode(&code),
152 crate::util::pct_encode(&req.state)
153 )))
154}
155
156#[derive(Debug, Serialize)]
158pub struct DesktopToken {
159 pub token: String,
160 pub token_name: String,
161 pub expires_at: Option<chrono::DateTime<Utc>>,
162 pub user: Actor,
163}
164
165pub async fn exchange(
170 pool: &PgPool,
171 code: &str,
172 verifier: &str,
173 sso_idle_days: u32,
174) -> AppResult<DesktopToken> {
175 let invalid = || AppError::BadRequest("The sign-in code is invalid or expired.".into());
176 if !code.starts_with(CODE_PREFIX) || !(43..=128).contains(&verifier.len()) {
177 return Err(invalid());
178 }
179 let row = sqlx::query_as::<_, (Uuid, String, String)>(
181 "DELETE FROM desktop_auth_codes WHERE code_hash = $1 AND expires_at > now()
182 RETURNING user_id, challenge, device",
183 )
184 .bind(auth::hash_token(code))
185 .fetch_optional(pool)
186 .await?;
187 let Some((user_id, challenge, device)) = row else {
188 return Err(invalid());
189 };
190 if challenge_of(verifier) != challenge {
191 return Err(invalid());
192 }
193 let actor = users::actor(pool, user_id).await?;
194 let token_name =
195 domain::limit_text("Token name", &format!("TeamCraft Desktop – {device}"), 100)?;
196 let asked = users::TokenLifetime::IdleDays(TOKEN_DAYS);
197 let lifetime = users::token_lifetime_for(pool, user_id, asked, sso_idle_days).await?;
198 let (record, token) = users::create_token(
199 pool,
200 &actor,
201 &token_name,
202 lifetime,
203 domain::TokenScope::Full,
204 )
205 .await?;
206 Ok(DesktopToken {
207 token,
208 token_name: record.name,
209 expires_at: record.expires_at,
210 user: actor,
211 })
212}
213
214pub async fn purge_expired(pool: &PgPool) -> AppResult<u64> {
216 Ok(
217 sqlx::query("DELETE FROM desktop_auth_codes WHERE expires_at <= now()")
218 .execute(pool)
219 .await?
220 .rows_affected(),
221 )
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
229
230 #[test]
231 fn pkce_s256() {
232 assert_eq!(
234 challenge_of("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
235 CHALLENGE
236 );
237 }
238
239 #[test]
240 fn requests_are_validated() {
241 let ok =
242 AuthorizeRequest::parse("53682", "abcdefghijklmnop", CHALLENGE, " Leo's\nMacBook ")
243 .unwrap();
244 assert_eq!(ok.port, 53682);
245 assert_eq!(ok.device, "Leo'sMacBook");
246 assert_eq!(
247 ok.callback("code=x&state=y"),
248 "http://127.0.0.1:53682/callback?code=x&state=y"
249 );
250 let state = "abcdefghijklmnop";
251 assert!(
252 AuthorizeRequest::parse("80", state, CHALLENGE, "pc").is_err(),
253 "privileged port"
254 );
255 assert!(AuthorizeRequest::parse("70000", state, CHALLENGE, "pc").is_err());
256 assert!(AuthorizeRequest::parse("x", state, CHALLENGE, "pc").is_err());
257 assert!(AuthorizeRequest::parse("53682", "short", CHALLENGE, "pc").is_err());
258 assert!(AuthorizeRequest::parse("53682", "abcdefghijklmnop<", CHALLENGE, "pc").is_err());
259 assert!(AuthorizeRequest::parse("53682", state, "too-short", "pc").is_err());
260 let unnamed = AuthorizeRequest::parse("53682", state, CHALLENGE, " ").unwrap();
261 assert_eq!(unnamed.device, "unknown computer");
262 let long = AuthorizeRequest::parse("53682", state, CHALLENGE, &"x".repeat(200)).unwrap();
263 assert_eq!(long.device.len(), 64);
264 }
265}