Skip to main content

heddle_client/grpc_hosted/
session.rs

1//! Hosted-session open seam.
2//!
3//! "Open a usable hosted session for a remote" used to be hand-assembled at
4//! every hosted entry point (push, pull, fetch, clone, support, approval,
5//! lazy hydration): resolve the auth token, fall back to the credential
6//! store, attach the proof key, build the validated client config, connect,
7//! then run mandatory credential rotation. This module owns that assembly so
8//! the command modules choose only intent + remote and call one seam.
9
10use std::net::SocketAddr;
11
12use anyhow::Result;
13use cli_shared::{ClientConfig, UserConfig};
14use wire::{AuthToken, ProtocolError};
15
16use crate::{credentials, grpc_hosted::HostedGrpcClient};
17
18/// How a hosted session resolves its auth token.
19pub enum HostedAuthMode {
20    /// Use only the token from env/user config (`remote_token`). Used by
21    /// fetch, support, approval, clone, and lazy hydration.
22    ConfigToken,
23    /// Use the config token, falling back to the per-server credential store
24    /// (and its proof key) when no config token is present. Used by push and
25    /// pull.
26    CredentialFallback,
27}
28
29/// A validated, connectable hosted-session configuration.
30///
31/// Building runs the fallible TLS/auth validation up front, so callers that
32/// must not leave partial on-disk artifacts (clone, push) — or that build the
33/// config on one thread and connect on another (lazy hydration) — can
34/// prevalidate before any irreversible work, then `connect()` afterwards.
35/// Callers with no such ordering constraint use
36/// [`HostedGrpcClient::open_session`], which builds and connects in one call.
37pub struct HostedSession {
38    config: ClientConfig,
39}
40
41impl HostedSession {
42    /// Resolve auth + build the validated client config for a hosted session.
43    /// Owns credential-store fallback (per `mode`), server-key attachment, and
44    /// proof-key attachment — the assembly the command modules used to
45    /// hand-roll.
46    pub fn build(
47        user_config: &UserConfig,
48        server_key: Option<String>,
49        mode: HostedAuthMode,
50    ) -> Result<Self> {
51        let (token, credential_proof_key) = match mode {
52            HostedAuthMode::ConfigToken => (user_config.remote_token()?, None),
53            HostedAuthMode::CredentialFallback => {
54                let mut token = user_config.remote_token()?;
55                let mut credential_proof_key = None;
56                if token.is_none()
57                    && let Some(ref key) = server_key
58                    && let Ok(Some(cred)) = credentials::resolve_credential_for_server(key)
59                {
60                    token = Some(AuthToken::new(cred.token, "credential-store"));
61                    credential_proof_key = cred.private_key_pem;
62                }
63                (token, credential_proof_key)
64            }
65        };
66
67        let mut config = user_config.heddle_client_config(token)?;
68        if let Some(key) = server_key {
69            config = config.with_server_key(key);
70        }
71        if let Some(pem) = credential_proof_key
72            && config.auth_proof_key_pem.is_none()
73        {
74            config = config.with_auth_proof_key_pem(pem);
75        }
76        Ok(Self { config })
77    }
78
79    /// Connect and run mandatory credential rotation.
80    ///
81    /// The rotation MUST run immediately after connect — every hosted entry
82    /// point relies on a fresh token before its first RPC. This is the single
83    /// place that pairs connect with rotation; see the source-presence guard
84    /// in this module's tests.
85    pub async fn connect(&self, addr: SocketAddr) -> Result<HostedGrpcClient, ProtocolError> {
86        let mut client = HostedGrpcClient::connect(addr, &self.config).await?;
87        client.auto_rotate_if_needed().await;
88        Ok(client)
89    }
90}
91
92impl HostedGrpcClient {
93    /// Open a usable hosted session in one call: resolve auth, build the
94    /// validated client config, connect, and run mandatory rotation. Callers
95    /// that must prevalidate the config before irreversible work build a
96    /// [`HostedSession`] first, then [`HostedSession::connect`].
97    pub async fn open_session(
98        addr: SocketAddr,
99        user_config: &UserConfig,
100        server_key: Option<String>,
101        mode: HostedAuthMode,
102    ) -> Result<Self> {
103        Ok(HostedSession::build(user_config, server_key, mode)?
104            .connect(addr)
105            .await?)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    //! The connect/rotate invariant now lives here, in the one seam every
112    //! hosted entry point opens its session through. This source-presence
113    //! guard replaces the per-call-site rotation checks: rotation MUST run
114    //! immediately after `HostedGrpcClient::connect`, or a process whose
115    //! cached token has slipped past expiry hits an auth failure on its first
116    //! RPC even though the rotation data is on disk.
117
118    #[test]
119    fn session_connect_rotates_credentials_after_connect() {
120        let source = include_str!("session.rs");
121        let connect_idx = source
122            .find("HostedGrpcClient::connect(addr, &self.config)")
123            .expect("session.rs must connect with the resolved addr");
124        let after_connect = &source[connect_idx..];
125        let rotate_offset = after_connect
126            .find("auto_rotate_if_needed")
127            .expect("auto_rotate_if_needed must appear in session.rs");
128        assert!(
129            rotate_offset < 400,
130            "auto_rotate_if_needed must follow HostedGrpcClient::connect within the \
131             same async block (found {rotate_offset} chars later)",
132        );
133    }
134}