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, 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                {
59                    // Propagate a malformed credentials.toml instead of
60                    // swallowing it: a parse error here (e.g. a missing
61                    // `subject` field) used to fall through to an
62                    // unauthenticated request, which the server rejects with
63                    // the opaque "missing authorization metadata" — hiding the
64                    // real cause. The `?` surfaces the underlying
65                    // "parsing <path>: <toml error>" so the user can fix the
66                    // file. A *missing* file still returns Ok(None) and falls
67                    // back cleanly.
68                    if let Some(cred) = credentials::resolve_credential_for_server(key)? {
69                        token = Some(AuthToken::new(cred.token, "credential-store"));
70                        credential_proof_key = cred.private_key_pem;
71                    }
72                }
73                (token, credential_proof_key)
74            }
75        };
76
77        let mut config = user_config.heddle_client_config(token)?;
78        if let Some(key) = server_key {
79            config = config.with_server_key(key);
80        }
81        if let Some(pem) = credential_proof_key
82            && config.auth_proof_key_pem.is_none()
83        {
84            config = config.with_auth_proof_key_pem(pem);
85        }
86        Ok(Self { config })
87    }
88
89    /// Connect and run mandatory credential rotation.
90    ///
91    /// The rotation MUST run immediately after connect — every hosted entry
92    /// point relies on a fresh token before its first RPC. This is the single
93    /// place that pairs connect with rotation; see the source-presence guard
94    /// in this module's tests.
95    pub async fn connect(&self, addr: SocketAddr) -> Result<HostedGrpcClient, ProtocolError> {
96        let mut client = HostedGrpcClient::connect(addr, &self.config).await?;
97        client.auto_rotate_if_needed().await;
98        Ok(client)
99    }
100}
101
102impl HostedGrpcClient {
103    /// Open a usable hosted session in one call: resolve auth, build the
104    /// validated client config, connect, and run mandatory rotation. Callers
105    /// that must prevalidate the config before irreversible work build a
106    /// [`HostedSession`] first, then [`HostedSession::connect`].
107    pub async fn open_session(
108        addr: SocketAddr,
109        user_config: &UserConfig,
110        server_key: Option<String>,
111        mode: HostedAuthMode,
112    ) -> Result<Self> {
113        Ok(HostedSession::build(user_config, server_key, mode)?
114            .connect(addr)
115            .await?)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    //! The connect/rotate invariant now lives here, in the one seam every
122    //! hosted entry point opens its session through. This source-presence
123    //! guard replaces the per-call-site rotation checks: rotation MUST run
124    //! immediately after `HostedGrpcClient::connect`, or a process whose
125    //! cached token has slipped past expiry hits an auth failure on its first
126    //! RPC even though the rotation data is on disk.
127
128    #[test]
129    fn session_connect_rotates_credentials_after_connect() {
130        let source = include_str!("session.rs");
131        let connect_idx = source
132            .find("HostedGrpcClient::connect(addr, &self.config)")
133            .expect("session.rs must connect with the resolved addr");
134        let after_connect = &source[connect_idx..];
135        let rotate_offset = after_connect
136            .find("auto_rotate_if_needed")
137            .expect("auto_rotate_if_needed must appear in session.rs");
138        assert!(
139            rotate_offset < 400,
140            "auto_rotate_if_needed must follow HostedGrpcClient::connect within the \
141             same async block (found {rotate_offset} chars later)",
142        );
143    }
144}