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}