Skip to main content

teamcraft/services/
desktop.rs

1//! Sign-in of the TeamCraft desktop app through the browser.
2//!
3//! ```text
4//!  desktop app                         browser (signed in, e.g. via SSO)        server
5//!  ─────────────────────────────────────────────────────────────────────────────────────
6//!  verifier, state; listen on 127.0.0.1:<port>
7//!  open /desktop/authorize?port&state&challenge&device ──▶ confirmation page
8//!                                                   "Allow" ──▶ one-time code (5 min)
9//!  ◀── 303 http://127.0.0.1:<port>/callback?code&state ──────────────────────
10//!  POST /api/v1/desktop/token {code, verifier} ──────────────────────────────▶ token
11//! ```
12//!
13//! The app never sees a password, so single sign-on works unchanged. The code
14//! alone is useless: it is bound to the PKCE challenge of the app that
15//! started the sign-in (RFC 7636, S256) and valid once for five minutes.
16
17use 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
32/// Lifetime of a one-time code.
33const CODE_TTL_MINUTES: i64 = 5;
34/// The personal access token of a desktop sign-in expires after this many
35/// days without use; every use renews it (a sliding lifetime). It keeps the
36/// full scope: the app reads, uploads, checks in and reviews like the web UI.
37/// A copy left on an unused computer dies within a month, an app in daily use
38/// never has to sign in again.
39pub const TOKEN_DAYS: i64 = 30;
40const CODE_PREFIX: &str = "tc_dsk_";
41
42/// A validated sign-in request of the desktop app.
43#[derive(Clone, Debug, Serialize)]
44pub struct AuthorizeRequest {
45    /// Loopback port of the app (the redirect always goes to 127.0.0.1).
46    pub port: u16,
47    pub state: String,
48    pub challenge: String,
49    /// Computer name shown to the user and used for the token name.
50    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        // base64url of a SHA-256 digest without padding
72        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    /// Where the browser is sent after the decision – always the loopback
100    /// interface of the user's own computer.
101    pub fn callback(&self, query: &str) -> String {
102        format!("http://127.0.0.1:{}/callback?{query}", self.port)
103    }
104}
105
106/// PKCE S256: `base64url(SHA-256(verifier))`.
107pub fn challenge_of(verifier: &str) -> String {
108    URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
109}
110
111/// The user allowed the sign-in: stores a one-time code and returns the
112/// callback URL that hands it to the app.
113///
114/// # Invariants
115///
116/// * **Permission:** any signed-in actor; the code belongs to the actor.
117/// * **Locks:** none.
118/// * **Data:** only the hash of the one-time code is stored, with the PKCE
119///   challenge and an expiry; the code itself appears only in the returned
120///   callback URL.
121/// * **Audit:** `desktop.authorize`.
122/// * **Notifications / webhooks:** none.
123/// * **Atomic:** one transaction.
124pub 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/// Result of a successful code exchange.
157#[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
165/// Exchanges a one-time code plus the PKCE verifier for a personal access
166/// token. Every failure looks the same to the caller. The token expires
167/// after [`TOKEN_DAYS`] without use – for a single sign-on user after
168/// `sso_idle_days` if that is shorter (`TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS`).
169pub 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    // One-time: the row is deleted whether or not the verifier matches.
180    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
214/// Removes expired codes (housekeeping).
215pub 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        // RFC 7636, appendix B
233        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}