Skip to main content

rskit_git/auth/
provider.rs

1//! Auth provider abstraction and a small toolkit of generic implementations.
2//!
3//! [`AuthProvider`] is the seam git backends use to resolve transport and
4//! signing configuration at call time. The provided implementations are
5//! forge-agnostic building blocks: callers (applications) decide policy such as
6//! which environment variables carry a token by composing them, typically via a
7//! [`ChainAuthProvider`]. No provider here hard-codes a forge-specific name.
8
9use std::sync::Arc;
10
11use rskit_errors::AppResult;
12use rskit_util::{SecretString, env};
13
14use super::{SigningConfig, TransportAuth};
15
16/// Default username used for token-as-password HTTP basic auth when the caller
17/// does not override it. This is a generic transport convention for carrying a
18/// token in the username field, not a forge identifier; callers override it per
19/// remote via [`TransportAuth::Token`].
20pub const DEFAULT_TOKEN_USERNAME: &str = "x-access-token";
21
22/// Supplies transport and signing configuration for git backends.
23pub trait AuthProvider: Send + Sync {
24    /// Resolves transport auth for an optional remote name.
25    ///
26    /// Returns `Ok(None)` when this provider has nothing to offer, so a
27    /// [`ChainAuthProvider`] can fall through to the next provider.
28    fn transport_auth(&self, remote: Option<&str>) -> AppResult<Option<TransportAuth>>;
29
30    /// Resolves signing configuration for commit-producing operations.
31    ///
32    /// Defaults to `Ok(None)`; the seam allows wiring signing later without
33    /// touching existing providers.
34    fn signing_config(&self) -> AppResult<Option<SigningConfig>> {
35        Ok(None)
36    }
37}
38
39impl AuthProvider for Arc<dyn AuthProvider> {
40    fn transport_auth(&self, remote: Option<&str>) -> AppResult<Option<TransportAuth>> {
41        (**self).transport_auth(remote)
42    }
43
44    fn signing_config(&self) -> AppResult<Option<SigningConfig>> {
45        (**self).signing_config()
46    }
47}
48
49/// Provider that offers nothing, deferring to the backend transport default.
50///
51/// Behavior-preserving: a repository opened with this provider authenticates
52/// exactly as it did before the auth seam was wired.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct DefaultAuthProvider;
55
56impl AuthProvider for DefaultAuthProvider {
57    fn transport_auth(&self, _remote: Option<&str>) -> AppResult<Option<TransportAuth>> {
58        Ok(None)
59    }
60}
61
62/// Provider that always returns a fixed [`TransportAuth`].
63///
64/// Use for explicitly configured credentials (an SSH key, the SSH agent, or a
65/// caller-held token).
66#[derive(Debug, Clone)]
67pub struct StaticAuthProvider {
68    auth: TransportAuth,
69}
70
71impl StaticAuthProvider {
72    /// Create a provider that always yields `auth`.
73    #[must_use]
74    pub fn new(auth: TransportAuth) -> Self {
75        Self { auth }
76    }
77}
78
79impl AuthProvider for StaticAuthProvider {
80    fn transport_auth(&self, _remote: Option<&str>) -> AppResult<Option<TransportAuth>> {
81        Ok(Some(self.auth.clone()))
82    }
83}
84
85/// Provider that reads a token from the first present, non-empty environment
86/// variable in an ordered list and exposes it as token-as-password auth.
87///
88/// The variable names are supplied by the caller — this type owns the mechanism
89/// (read env → build [`TransportAuth::Token`]), never the policy of which
90/// variables or which forge. Returns `Ok(None)` when none of the variables are
91/// set, so it is harmless to inject unconditionally in a chain.
92#[derive(Debug, Clone)]
93pub struct EnvTokenAuthProvider {
94    vars: Vec<String>,
95    username: String,
96}
97
98impl EnvTokenAuthProvider {
99    /// Create a provider that reads the single environment variable `name`.
100    #[must_use]
101    pub fn with_var(name: impl Into<String>) -> Self {
102        Self {
103            vars: vec![name.into()],
104            username: DEFAULT_TOKEN_USERNAME.to_string(),
105        }
106    }
107
108    /// Create a provider that reads the first present variable in `names`.
109    #[must_use]
110    pub fn with_vars<I, S>(names: I) -> Self
111    where
112        I: IntoIterator<Item = S>,
113        S: Into<String>,
114    {
115        Self {
116            vars: names.into_iter().map(Into::into).collect(),
117            username: DEFAULT_TOKEN_USERNAME.to_string(),
118        }
119    }
120
121    /// Override the basic-auth username paired with the token.
122    #[must_use]
123    pub fn with_username(mut self, username: impl Into<String>) -> Self {
124        self.username = username.into();
125        self
126    }
127}
128
129impl AuthProvider for EnvTokenAuthProvider {
130    fn transport_auth(&self, _remote: Option<&str>) -> AppResult<Option<TransportAuth>> {
131        Ok(self
132            .vars
133            .iter()
134            .find_map(|var| env::get_non_empty(var))
135            .map(|token| TransportAuth::Token {
136                username: Some(self.username.clone()),
137                token: SecretString::new(token),
138            }))
139    }
140}
141
142/// Provider that consults an ordered list of providers; the first `Some` wins,
143/// for both transport and signing resolution.
144#[derive(Clone, Default)]
145pub struct ChainAuthProvider {
146    providers: Vec<Arc<dyn AuthProvider>>,
147}
148
149impl ChainAuthProvider {
150    /// Create a chain from an ordered list of providers.
151    #[must_use]
152    pub fn new(providers: Vec<Arc<dyn AuthProvider>>) -> Self {
153        Self { providers }
154    }
155
156    /// Append a provider to the end of the chain.
157    #[must_use]
158    pub fn with(mut self, provider: Arc<dyn AuthProvider>) -> Self {
159        self.providers.push(provider);
160        self
161    }
162}
163
164impl std::fmt::Debug for ChainAuthProvider {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("ChainAuthProvider")
167            .field("providers", &self.providers.len())
168            .finish()
169    }
170}
171
172impl AuthProvider for ChainAuthProvider {
173    fn transport_auth(&self, remote: Option<&str>) -> AppResult<Option<TransportAuth>> {
174        for provider in &self.providers {
175            if let Some(auth) = provider.transport_auth(remote)? {
176                return Ok(Some(auth));
177            }
178        }
179        Ok(None)
180    }
181
182    fn signing_config(&self) -> AppResult<Option<SigningConfig>> {
183        for provider in &self.providers {
184            if let Some(config) = provider.signing_config()? {
185                return Ok(Some(config));
186            }
187        }
188        Ok(None)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    // Cargo sets `CARGO_PKG_NAME` for the test process, so we can assert the
197    // present-var path without the (edition-2024-unsafe) `std::env::set_var`.
198    const PRESENT_VAR: &str = "CARGO_PKG_NAME";
199    const PRESENT_VALUE: &str = "rskit-git";
200    const ABSENT_VAR: &str = "RSKIT_GIT_AUTH_TEST_ABSENT_VAR_9F3A";
201
202    #[test]
203    fn default_provider_offers_nothing() {
204        let provider = DefaultAuthProvider;
205        assert_eq!(provider.transport_auth(None).expect("resolve"), None);
206        assert!(provider.signing_config().expect("resolve").is_none());
207    }
208
209    #[test]
210    fn static_provider_returns_fixed_transport() {
211        let provider = StaticAuthProvider::new(TransportAuth::SshAgent {
212            username: "git".to_string(),
213        });
214        assert_eq!(
215            provider.transport_auth(Some("origin")).expect("resolve"),
216            Some(TransportAuth::SshAgent {
217                username: "git".to_string(),
218            })
219        );
220    }
221
222    #[test]
223    fn env_token_provider_reads_present_variable() {
224        let provider = EnvTokenAuthProvider::with_vars([ABSENT_VAR, PRESENT_VAR]);
225        let auth = provider.transport_auth(None).expect("resolve");
226        assert_eq!(
227            auth,
228            Some(TransportAuth::Token {
229                username: Some(DEFAULT_TOKEN_USERNAME.to_string()),
230                token: SecretString::new(PRESENT_VALUE),
231            })
232        );
233    }
234
235    #[test]
236    fn env_token_provider_honors_username_override() {
237        let provider = EnvTokenAuthProvider::with_var(PRESENT_VAR).with_username("token-user");
238        let auth = provider.transport_auth(None).expect("resolve");
239        assert_eq!(
240            auth,
241            Some(TransportAuth::Token {
242                username: Some("token-user".to_string()),
243                token: SecretString::new(PRESENT_VALUE),
244            })
245        );
246    }
247
248    #[test]
249    fn env_token_provider_absent_variable_is_none() {
250        let provider = EnvTokenAuthProvider::with_var(ABSENT_VAR);
251        assert_eq!(provider.transport_auth(None).expect("resolve"), None);
252    }
253
254    #[test]
255    fn chain_returns_first_some() {
256        let chain = ChainAuthProvider::new(vec![
257            Arc::new(EnvTokenAuthProvider::with_var(ABSENT_VAR)),
258            Arc::new(EnvTokenAuthProvider::with_var(PRESENT_VAR)),
259            Arc::new(StaticAuthProvider::new(TransportAuth::SshAgent {
260                username: "unused".to_string(),
261            })),
262        ]);
263        let auth = chain.transport_auth(None).expect("resolve");
264        assert_eq!(
265            auth,
266            Some(TransportAuth::Token {
267                username: Some(DEFAULT_TOKEN_USERNAME.to_string()),
268                token: SecretString::new(PRESENT_VALUE),
269            })
270        );
271    }
272
273    #[test]
274    fn chain_falls_through_to_none() {
275        let chain = ChainAuthProvider::new(vec![
276            Arc::new(EnvTokenAuthProvider::with_var(ABSENT_VAR)),
277            Arc::new(DefaultAuthProvider),
278        ]);
279        assert_eq!(chain.transport_auth(None).expect("resolve"), None);
280        assert!(chain.signing_config().expect("resolve").is_none());
281    }
282}