Skip to main content

aperion_shield/orgmode/
smartflow_provider.rs

1//! Smartflow-mediated identity gate.
2//!
3//! When org mode is active the standalone Shield delegates identity
4//! verification to Smartflow instead of running its own OAuth dance.
5//! Smartflow speaks to the real ID.me (or any configured OIDC IdP) on
6//! behalf of the whole org and hands the standalone a short-lived,
7//! HMAC-signed assertion.
8//!
9//! This module does NOT implement [`crate::IdentityProvider`] -- that
10//! trait is tied to the local callback server. Instead, the org-mode
11//! identity handler in `main.rs` calls `SmartflowProvider::resolve`
12//! directly when an enrolled Shield encounters a
13//! [`crate::Decision::IdentityVerification`].
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use chrono::{DateTime, Utc};
19use thiserror::Error;
20use tokio::time::sleep;
21
22use super::client::{IdentityCheckRequest, IdentityCheckResponse, OrgApi};
23use crate::IdentityRequirement;
24
25/// Default polling cadence while we wait for the user to complete the
26/// browser-side verify flow.
27const POLL_EVERY: Duration = Duration::from_secs(2);
28
29/// Outcome of [`SmartflowProvider::resolve`].
30#[derive(Debug)]
31pub enum ResolveOutcome {
32    /// User is already verified for this scope and LOA. The standalone
33    /// should release the held tool call immediately. The contained
34    /// [`SmartflowProof`] is a server-signed assertion we log for audit.
35    Verified(SmartflowProof),
36
37    /// User needs to verify. The standalone surfaces the `verify_url`
38    /// to the developer and polls until either the proof lands or
39    /// `hold_seconds` elapses (then returns [`ResolveOutcome::HoldExpired`]).
40    HoldExpired { verify_url: String, challenge_id: String },
41
42    /// Provider is not configured on Smartflow's side (e.g. id_me with
43    /// no sandbox creds). Treated as a deny.
44    ProviderUnready { provider: String, message: String },
45
46    /// Hard error (network, server fault). Treated as a deny -- we
47    /// don't want to silently allow a gated call when the control plane
48    /// is unreachable.
49    Error(SmartflowError),
50}
51
52#[derive(Debug, Clone)]
53pub struct SmartflowProof {
54    pub provider: String,
55    pub subject: String,
56    pub loa: u8,
57    pub expires_at: DateTime<Utc>,
58    /// Hex-encoded HMAC over the canonical proof fields. The standalone
59    /// stores this in the audit row but doesn't re-verify it locally --
60    /// the vkey-authenticated HTTPS connection is the trust boundary.
61    pub signature: Option<String>,
62}
63
64#[derive(Debug, Error)]
65pub enum SmartflowError {
66    #[error("network/http: {0}")]
67    Net(String),
68    #[error("decode: {0}")]
69    Decode(String),
70    #[error("server response: {0}")]
71    Server(String),
72}
73
74pub struct SmartflowProvider {
75    api: Arc<OrgApi>,
76    /// How long we wait before giving up on a user verification.
77    /// Matches the local identity gate default (120 s).
78    pub hold_seconds: u64,
79}
80
81impl SmartflowProvider {
82    pub fn new(api: Arc<OrgApi>) -> Self {
83        Self {
84            api,
85            hold_seconds: 120,
86        }
87    }
88
89    pub fn with_hold_seconds(mut self, secs: u64) -> Self {
90        self.hold_seconds = secs;
91        self
92    }
93
94    /// Convert an engine [`IdentityRequirement`] into the wire DTO the
95    /// server expects.
96    fn requirement_to_request(req: &IdentityRequirement) -> IdentityCheckRequest {
97        IdentityCheckRequest {
98            provider: req.provider.clone(),
99            scope: req.scope.clone(),
100            allowed_subjects: req.allowed_subjects.iter().cloned().collect(),
101            min_loa: if req.loa == 0 { None } else { Some(req.loa) },
102            max_age_seconds: req.max_proof_age_seconds,
103        }
104    }
105
106    /// Resolve a requirement to an outcome. This is the single public
107    /// entry point the org-mode handler calls.
108    pub async fn resolve(&self, req: &IdentityRequirement) -> ResolveOutcome {
109        let wire = Self::requirement_to_request(req);
110        let first = match self.api.identity_check(&wire).await {
111            Ok(r) => r,
112            Err(super::client::OrgApiError::Http { status: 503, body }) => {
113                return ResolveOutcome::ProviderUnready {
114                    provider: req.provider.clone(),
115                    message: body,
116                };
117            }
118            Err(e) => return ResolveOutcome::Error(SmartflowError::Net(e.to_string())),
119        };
120
121        if first.verified {
122            return ResolveOutcome::Verified(proof_from_response(req, &first));
123        }
124
125        // The server returned a verify URL. Poll until the user
126        // completes the flow or we time out.
127        let (verify_url, challenge_id) = match (first.verify_url, first.challenge_id) {
128            (Some(u), Some(c)) => (u, c),
129            _ => {
130                return ResolveOutcome::Error(SmartflowError::Server(
131                    "server reported unverified but did not return verify_url + challenge_id"
132                        .into(),
133                ));
134            }
135        };
136
137        // Best-effort surface to the developer. The MCP middleman will
138        // also print a structured JSON-RPC error so the IDE can render
139        // a prompt; this stderr line gives a CLI / terminal user the
140        // URL even when the IDE is silent.
141        eprintln!(
142            "[shield] identity verification required for scope='{}' provider='{}'",
143            req.scope, req.provider
144        );
145        eprintln!("[shield] open: {}", verify_url);
146        eprintln!(
147            "[shield] holding tool call for {}s (challenge={})",
148            self.hold_seconds, challenge_id
149        );
150
151        let deadline = std::time::Instant::now() + Duration::from_secs(self.hold_seconds);
152        while std::time::Instant::now() < deadline {
153            sleep(POLL_EVERY).await;
154            match self.api.identity_result(&challenge_id).await {
155                Ok(r) if r.verified => {
156                    return ResolveOutcome::Verified(proof_from_response(req, &r));
157                }
158                Ok(_) => continue,
159                Err(super::client::OrgApiError::Http { status: 404, .. }) => {
160                    // Challenge expired server-side -- bail.
161                    break;
162                }
163                Err(e) => {
164                    log::warn!("[shield] identity_result poll error: {}", e);
165                    // Keep polling -- network blips shouldn't cancel
166                    // an in-progress verification.
167                }
168            }
169        }
170
171        ResolveOutcome::HoldExpired {
172            verify_url,
173            challenge_id,
174        }
175    }
176}
177
178fn proof_from_response(
179    req: &IdentityRequirement,
180    resp: &IdentityCheckResponse,
181) -> SmartflowProof {
182    SmartflowProof {
183        provider: req.provider.clone(),
184        subject: resp.subject.clone().unwrap_or_default(),
185        loa: resp.loa.unwrap_or(0),
186        expires_at: resp.expires_at.unwrap_or_else(Utc::now),
187        signature: resp.signature.clone(),
188    }
189}