haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! Signing a person in to Studio through Lys (ACCESS-002 R2).
//!
//! The authorization code flow runs here, never in the browser. The code
//! exchange itself is one call into lys-pass's sign-in helper, behind
//! [`Issuer`], so the token work lives once for every product. A state is
//! used once, and only from the browser that started it. A pass about to
//! expire is refreshed. If Lys cannot be asked, a pass that still holds is
//! carried, and an expired one is refused by name.

use lys_pass::Error;
use serde_json::{Value, json};

use super::config::SignInBounds;
use super::session::{Live, Pending, Session, Sessions, lock, session_id};
pub use super::session::{SignedIn, Started};
use crate::serve::storage::Failure;
use std::sync::{Arc, Mutex};
use std::time::Instant;

/// How long before its expiry a pass is refreshed, in seconds.
const REFRESH_BEFORE: u64 = 60;

/// How long a started sign-in waits for its code, in seconds.
const SIGN_IN_FOR: u64 = 600;

/// lys-pass's sign-in helper, as the door calls it.
pub trait Issuer: Send + Sync + 'static {
    /// Verify an agent's hello without retaining its credential.
    ///
    /// # Errors
    /// The verifier's named refusal, including an absent assistant verifier.
    fn assistant_identity(
        &self,
        _agent: &str,
        _pass: &str,
        _store: &str,
    ) -> Result<String, Failure> {
        Err(Failure::new(
            "assistant_identity_unavailable",
            "the issuer has no assistant verifier",
        ))
    }

    /// Read the draft's authoritative holder, target and digest under the person's pass.
    ///
    /// # Errors
    /// Lys's refusal, or a bounded lookup that could not complete.
    fn assistant_draft(
        &self,
        _pass: &str,
        _draft: &str,
        _within: std::time::Duration,
    ) -> Result<Value, Failure> {
        Err(Failure::new(
            "lys_unavailable",
            "the issuer has no draft read route",
        ))
    }

    /// Decide a product draft using the person's pass.
    ///
    /// # Errors
    /// The issuer's named refusal. No seat is notified after a refusal.
    fn assistant_decide(
        &self,
        _pass: &str,
        _draft: &Value,
        _decision: &str,
        _within: std::time::Duration,
    ) -> Result<Value, Failure> {
        Err(Failure::new(
            "assistant_decision_unavailable",
            "the issuer has no draft decision route",
        ))
    }

    /// The person's sign-in authority, for start and draft links.
    ///
    /// # Errors
    /// Names an issuer without an assistant integration.
    fn assistant_url(&self) -> Result<&str, Failure> {
        Err(Failure::new(
            "assistant_identity_unavailable",
            "the issuer has no assistant authority",
        ))
    }
    /// The bounds this issuer was configured with.
    fn bounds(&self) -> SignInBounds {
        SignInBounds::default()
    }

    /// Start a sign-in: the Lys address, the state and the PKCE verifier.
    ///
    /// # Errors
    ///
    /// The helper's refusal by name.
    fn start(&self) -> Result<Started, Error>;

    /// Exchange `code` server-side with the app's credential, refusing a
    /// state whose `browser` cookie differs, and verify the pass.
    ///
    /// # Errors
    ///
    /// The helper's refusal by name.
    fn finish(
        &self,
        code: &str,
        state: &str,
        browser: &str,
        started: &Started,
    ) -> Result<SignedIn, Error>;

    /// A fresh pass for `refresh`.
    ///
    /// # Errors
    ///
    /// `lys_could_not_be_asked` when Lys cannot be asked, or another refusal
    /// by name.
    fn refresh(&self, refresh: &str) -> Result<SignedIn, Error>;
}

/// Where to send the browser, and the cookie value binding it to the state.
#[derive(Debug)]
pub struct Begun {
    /// The Lys authorization address.
    pub url: String,
    /// The browser-binding cookie's value.
    pub browser: String,
}

/// Studio's sign-in and its sessions.
pub struct Door<I> {
    issuer: Arc<I>,
    sessions: Arc<Sessions>,
    bounds: SignInBounds,
    assistant: Arc<Mutex<Option<Arc<super::assistant_state::Hub>>>>,
    pub(super) connections: Arc<std::sync::atomic::AtomicUsize>,
}

impl<I> Clone for Door<I> {
    fn clone(&self) -> Self {
        Self {
            issuer: Arc::clone(&self.issuer),
            sessions: Arc::clone(&self.sessions),
            bounds: self.bounds,
            assistant: Arc::clone(&self.assistant),
            connections: Arc::clone(&self.connections),
        }
    }
}

impl<I: Issuer> Door<I> {
    /// A door signing people in through `issuer`.
    #[must_use]
    pub fn new(issuer: I) -> Self {
        Self {
            bounds: issuer.bounds(),
            issuer: Arc::new(issuer),
            sessions: Arc::new(Sessions::default()),
            assistant: Arc::new(Mutex::new(None)),
            connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
        }
    }

    /// The sign-in helper this door calls.
    #[must_use]
    pub fn issuer(&self) -> &I {
        &self.issuer
    }

    pub(super) fn set_assistant(
        &self,
        hub: Arc<super::assistant_state::Hub>,
    ) -> Result<(), Failure> {
        let mut assistant = lock(&self.assistant)?;
        if assistant.is_some() {
            return Err(Failure::new(
                "assistant_already_listening",
                "the door already has an assistant socket",
            ));
        }
        *assistant = Some(hub);
        drop(assistant);
        Ok(())
    }

    pub(super) fn assistant(&self) -> Result<Arc<super::assistant_state::Hub>, Failure> {
        lock(&self.assistant)?.clone().ok_or_else(|| {
            Failure::new(
                "assistant_not_configured",
                "configure the door's assistant socket first",
            )
        })
    }

    /// Keep the cookie's lifetime equal to the configured session index lifetime.
    #[must_use]
    pub(super) const fn session_seconds(&self) -> u64 {
        self.bounds.session_seconds.get()
    }

    /// Start a sign-in at `now`.
    ///
    /// # Errors
    ///
    /// The helper's refusal by name.
    pub fn begin(&self, now: u64) -> Result<Begun, Failure> {
        self.expire(now)?;
        let mut pending = lock(&self.sessions.pending)?;
        if pending.len() >= self.bounds.max_pending.get() {
            return Err(Failure::new(
                "busy_pending_signins",
                format!(
                    "max_pending_signins={}; pending_signins={}",
                    self.bounds.max_pending,
                    pending.len()
                ),
            ));
        }
        let started = self.issuer.start().map_err(|error| named(&error))?;
        if pending.contains_key(&started.state) {
            return Err(Failure::new(
                "sign_in_state_conflict",
                "the issuer repeated a pending sign-in state",
            ));
        }
        let begun = Begun {
            url: started.url.clone(),
            browser: started.browser.clone(),
        };
        pending.insert(started.state.clone(), Pending { started, at: now });
        drop(pending);
        Ok(begun)
    }

    /// Finish the sign-in Lys returned with `code` and `state` to the browser
    /// holding the `browser` cookie, answering the new session's id.
    ///
    /// # Errors
    ///
    /// `sign_in_state_unknown` for a state not started here, already used
    /// or too old; otherwise the helper's refusal by name. A state is taken
    /// before its exchange, so it is never used twice.
    pub fn callback(
        &self,
        code: &str,
        state: &str,
        browser: &str,
        now: u64,
    ) -> Result<String, Failure> {
        self.expire(now)?;
        let pending = lock(&self.sessions.pending)?.remove(state);
        let Some(pending) = pending.filter(|pending| now.saturating_sub(pending.at) <= SIGN_IN_FOR)
        else {
            return Err(Failure::new(
                "sign_in_state_unknown",
                "this sign-in was not started here, was already used, or waited too long",
            ));
        };
        let signed_in = self
            .issuer
            .finish(code, state, browser, &pending.started)
            .map_err(|error| named(&error))?;
        self.expire(now)?;
        let id = session_id()?;
        let until = now.saturating_add(self.bounds.session_seconds.get());
        let until = if signed_in.refresh.is_some() {
            until
        } else {
            until.min(signed_in.expires_at)
        };
        lock(&self.sessions.live)?.insert(
            id.clone(),
            Live {
                until,
                valid: Arc::new(std::sync::atomic::AtomicBool::new(true)),
                state: Arc::new(Mutex::new(Session {
                    signed_in,
                    answered: now,
                    refused: None,
                    attempted: None,
                    refresh_finished: None,
                })),
            },
        );
        Ok(id)
    }

    /// The pass to present for session `id` at `now`, refreshed when it is
    /// about to expire.
    ///
    /// # Errors
    ///
    /// `session_unknown`; the refresh's refusal by name once the pass has
    /// expired; `session_expired` for an expired pass with no refresh token.
    pub fn pass(&self, id: &str, now: u64) -> Result<String, Failure> {
        self.pass_started(id, now, Instant::now())
    }

    fn pass_started(&self, id: &str, now: u64, requested: Instant) -> Result<String, Failure> {
        self.expire(now)?;
        let state = {
            let live = lock(&self.sessions.live)?;
            Arc::clone(
                &live
                    .get(id)
                    .ok_or_else(|| Failure::new("session_unknown", "sign in to Studio first"))?
                    .state,
            )
        };
        // The global index never waits for this session's network call.
        let mut session = lock(&state)?;
        let due = now.saturating_add(REFRESH_BEFORE) >= session.signed_in.expires_at;
        if due
            && session.attempted != Some(now)
            && session
                .refresh_finished
                .is_none_or(|finished| finished < requested)
            && let Some(refresh) = session.signed_in.refresh.clone()
        {
            session.attempted = Some(now);
            let answer = self.issuer.refresh(&refresh);
            session.refresh_finished = Some(Instant::now());
            match answer {
                Ok(fresh) => {
                    session.signed_in = fresh;
                    session.answered = now;
                    session.refused = None;
                }
                Err(error) => {
                    if now >= session.signed_in.expires_at {
                        if error.name() == "lys_could_not_be_asked" {
                            session.refused = Some(error.name().to_owned());
                            return Err(named(&error));
                        }
                        drop(session);
                        lock(&self.sessions.live)?.remove(id);
                        return Err(named(&error));
                    }
                    session.refused = Some(error.name().to_owned());
                }
            }
        }
        if now >= session.signed_in.expires_at {
            if let Some(code) = &session.refused {
                return Err(named_code(code));
            }
            drop(session);
            lock(&self.sessions.live)?.remove(id);
            return Err(Failure::new(
                "session_expired",
                "the Lys pass expired; sign in again",
            ));
        }
        let token = session.signed_in.token.clone();
        let expires_without_refresh = session
            .signed_in
            .refresh
            .is_none()
            .then_some(session.signed_in.expires_at);
        drop(session);
        let mut live = lock(&self.sessions.live)?;
        let current = live
            .get_mut(id)
            .filter(|current| Arc::ptr_eq(&current.state, &state))
            .ok_or_else(|| {
                Failure::new(
                    "session_unknown",
                    "this session is no longer live; sign in again",
                )
            })?;
        if let Some(expires) = expires_without_refresh {
            current.until = current.until.min(expires);
        }
        drop(live);
        Ok(token)
    }

    /// Settings' Lys line for session `id`: whose it is, when the pass
    /// expires, when Lys last answered, and the last refresh it refused.
    ///
    /// # Errors
    ///
    /// `session_unknown`.
    pub fn session(&self, id: &str) -> Result<Value, Failure> {
        self.session_at(id, crate::serve::pass::now())
    }

    /// Settings for a live session at the request's clock.
    ///
    /// # Errors
    ///
    /// Returns `session_unknown` for an expired or absent session, or `door_poisoned`.
    pub fn session_at(&self, id: &str, now: u64) -> Result<Value, Failure> {
        self.expire(now)?;
        let state = {
            let live = lock(&self.sessions.live)?;
            Arc::clone(
                &live
                    .get(id)
                    .ok_or_else(|| Failure::new("session_unknown", "sign in to Studio first"))?
                    .state,
            )
        };
        let session = lock(&state)?;
        let settings = json!({
            "holder": session.signed_in.holder,
            "expires": session.signed_in.expires_at,
            "lys_answered": session.answered,
            "refresh_refused": session.refused,
        });
        drop(session);
        Ok(settings)
    }

    pub(super) fn session_validity(
        &self,
        id: &str,
        now: u64,
    ) -> Result<Arc<std::sync::atomic::AtomicBool>, Failure> {
        self.expire(now)?;
        let live = lock(&self.sessions.live)?;
        live.get(id)
            .map(|session| Arc::clone(&session.valid))
            .ok_or_else(|| Failure::new("session_unknown", "sign in to Studio first"))
    }

    fn expire(&self, now: u64) -> Result<(), Failure> {
        lock(&self.sessions.pending)?
            .retain(|_, pending| now.saturating_sub(pending.at) < SIGN_IN_FOR);
        lock(&self.sessions.live)?.retain(|_, live| now < live.until);
        Ok(())
    }

    /// Sign session `id` out: the door forgets it, and with it the pass and
    /// the refresh token it held. Answers whether the door still held it.
    ///
    /// Lys is not asked to revoke the refresh token, because its provider
    /// serves no revocation or end-session address (only authorize, token,
    /// userinfo and jwks; lys-identity-server provider/endpoints.rs), and
    /// lys-pass's sign-in helper has no call for one. The token is forgotten
    /// here and lapses at Lys when it expires; it never left the door, so
    /// nothing else holds it. The person's own Lys session is Lys's and stays.
    ///
    /// # Errors
    ///
    /// `door_poisoned`.
    pub fn sign_out_at(&self, id: &str, now: u64) -> Result<bool, Failure> {
        self.expire(now)?;
        self.sign_out(id)
    }

    /// Forget this session and its credentials.
    ///
    /// # Errors
    ///
    /// Returns `door_poisoned` when the session index cannot be locked.
    pub fn sign_out(&self, id: &str) -> Result<bool, Failure> {
        Ok(lock(&self.sessions.live)?.remove(id).is_some())
    }
}

fn named(error: &Error) -> Failure {
    named_code(error.name())
}

fn named_code(code: &str) -> Failure {
    let words = match code {
        "lys_could_not_be_asked" => "Lys could not be asked",
        _ => "Lys refused the sign-in",
    };
    Failure::new(code, words)
}

#[cfg(test)]
#[path = "signin_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "signin_concurrency_tests.rs"]
mod concurrency_tests;

#[cfg(test)]
#[path = "signin_expiry_tests.rs"]
mod expiry_tests;