Skip to main content

tailscale_rest/
credentials.rs

1//! Finding a control-plane credential in the environment.
2//!
3//! Three shapes are accepted, in a fixed order of precedence: an API access
4//! token, an OAuth client, and a federated identity backed by a JWT on disk. The
5//! order is fixed rather than "whichever is set" so that an operator who leaves
6//! an old token in a shell profile gets a predictable answer instead of a
7//! lottery.
8//!
9//! `ApiKey` and `API_KEY_ENV` keep the ecosystem's spelling because the variable
10//! they read is `TAILSCALE_API_KEY` and an operator setting it should be able to
11//! find it here. `CONTEXT.md` governs the prose, which says "API access token".
12
13use std::path::PathBuf;
14
15use crate::secret::Secret;
16
17pub const API_KEY_ENV: &str = "TAILSCALE_API_KEY";
18pub const OAUTH_CLIENT_ID_ENV: &str = "TAILSCALE_OAUTH_CLIENT_ID";
19pub const OAUTH_CLIENT_SECRET_ENV: &str = "TAILSCALE_OAUTH_CLIENT_SECRET";
20pub const OAUTH_SCOPES_ENV: &str = "TAILSCALE_OAUTH_SCOPES";
21pub const OAUTH_JWT_FILE_ENV: &str = "TAILSCALE_OAUTH_JWT_FILE";
22pub const TAILNET_ENV: &str = "TAILSCALE_TAILNET";
23
24/// What the API accepts to mean "the tailnet this credential belongs to".
25pub const DEFAULT_TAILNET: &str = "-";
26
27/// Every variable this module reads, for the diagnosis subcommand and for the
28/// documentation to stay in step with the code.
29pub const ENV_VARS: &[&str] = &[
30    API_KEY_ENV,
31    OAUTH_CLIENT_ID_ENV,
32    OAUTH_CLIENT_SECRET_ENV,
33    OAUTH_SCOPES_ENV,
34    OAUTH_JWT_FILE_ENV,
35    TAILNET_ENV,
36];
37
38/// How this server proves who it is to the control plane.
39#[derive(Debug, Clone)]
40pub enum Credentials {
41    /// A user's own API access token, sent as a bearer token. It expires on the
42    /// day the admin console gave it and is never renewed from this side.
43    ///
44    /// Named for `TAILSCALE_API_KEY`, the variable it comes from.
45    ApiKey(Secret),
46    /// An OAuth client, exchanged for a short-lived access token.
47    OauthClient {
48        client_id: String,
49        client_secret: Secret,
50        scopes: Vec<String>,
51    },
52    /// A workload identity: a JWT written by the platform, exchanged for an
53    /// access token. The file is read at exchange time, never cached, because
54    /// the platform rotates it underneath us.
55    Federated {
56        client_id: Option<String>,
57        jwt_file: PathBuf,
58        scopes: Vec<String>,
59    },
60}
61
62impl Credentials {
63    /// Read a credential from the process environment.
64    pub fn from_env() -> Option<Self> {
65        Self::from_source(|key| std::env::var(key).ok())
66    }
67
68    /// Read a credential from an arbitrary source.
69    ///
70    /// Setting an environment variable is `unsafe` in this edition and the
71    /// workspace forbids `unsafe`, so the tests go through here instead.
72    pub fn from_source(source: impl Fn(&str) -> Option<String>) -> Option<Self> {
73        let get = |key: &str| {
74            source(key)
75                .map(|v| v.trim().to_owned())
76                .filter(|v| !v.is_empty())
77        };
78        let scopes = || {
79            get(OAUTH_SCOPES_ENV).map_or_else(Vec::new, |raw| {
80                raw.split([',', ' '])
81                    .map(str::trim)
82                    .filter(|s| !s.is_empty())
83                    .map(str::to_owned)
84                    .collect()
85            })
86        };
87
88        if let Some(key) = get(API_KEY_ENV) {
89            return Some(Self::ApiKey(Secret::new(key)));
90        }
91        if let (Some(client_id), Some(secret)) =
92            (get(OAUTH_CLIENT_ID_ENV), get(OAUTH_CLIENT_SECRET_ENV))
93        {
94            return Some(Self::OauthClient {
95                client_id,
96                client_secret: Secret::new(secret),
97                scopes: scopes(),
98            });
99        }
100        if let Some(jwt_file) = get(OAUTH_JWT_FILE_ENV) {
101            return Some(Self::Federated {
102                client_id: get(OAUTH_CLIENT_ID_ENV),
103                jwt_file: PathBuf::from(jwt_file),
104                scopes: scopes(),
105            });
106        }
107        None
108    }
109
110    /// How this credential is described in diagnostics. Never the value.
111    pub const fn kind(&self) -> &'static str {
112        match self {
113            Self::ApiKey(_) => "API access token",
114            Self::OauthClient { .. } => "OAuth client",
115            Self::Federated { .. } => "federated identity",
116        }
117    }
118}
119
120/// The tailnet these credentials act on.
121pub fn tailnet_from_env() -> String {
122    tailnet_from_source(|key| std::env::var(key).ok())
123}
124
125/// The tailnet these credentials act on, from an arbitrary source.
126pub fn tailnet_from_source(source: impl Fn(&str) -> Option<String>) -> String {
127    source(TAILNET_ENV)
128        .map(|v| v.trim().to_owned())
129        .filter(|v| !v.is_empty())
130        .unwrap_or_else(|| DEFAULT_TAILNET.to_owned())
131}
132
133#[cfg(test)]
134mod tests {
135    use std::collections::HashMap;
136
137    use super::*;
138
139    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
140        let map: HashMap<String, String> = pairs
141            .iter()
142            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
143            .collect();
144        move |key| map.get(key).cloned()
145    }
146
147    #[test]
148    fn an_api_key_is_read() {
149        let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-def")]))
150            .expect("a key is a credential");
151        match creds {
152            Credentials::ApiKey(key) => assert_eq!(key.expose(), "tskey-api-example-def"),
153            other => panic!("expected an API access token, got {other:?}"),
154        }
155    }
156
157    #[test]
158    fn an_api_key_wins_over_an_oauth_client() {
159        let creds = Credentials::from_source(env(&[
160            (API_KEY_ENV, "tskey-api-example-def"),
161            (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
162            (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
163        ]))
164        .expect("a credential");
165        assert_eq!(creds.kind(), "API access token");
166    }
167
168    #[test]
169    fn an_oauth_client_wins_over_a_federated_identity() {
170        let creds = Credentials::from_source(env(&[
171            (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
172            (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
173            (OAUTH_JWT_FILE_ENV, "/run/secrets/token"),
174        ]))
175        .expect("a credential");
176        assert_eq!(creds.kind(), "OAuth client");
177    }
178
179    #[test]
180    fn a_jwt_file_alone_is_a_federated_identity() {
181        let creds = Credentials::from_source(env(&[(OAUTH_JWT_FILE_ENV, "/run/secrets/token")]))
182            .expect("a credential");
183        match creds {
184            Credentials::Federated {
185                client_id,
186                jwt_file,
187                ..
188            } => {
189                assert_eq!(client_id, None);
190                assert_eq!(jwt_file, PathBuf::from("/run/secrets/token"));
191            }
192            other => panic!("expected a federated identity, got {other:?}"),
193        }
194    }
195
196    #[test]
197    fn half_an_oauth_client_is_not_a_credential() {
198        assert!(Credentials::from_source(env(&[(OAUTH_CLIENT_ID_ENV, "kExAmPlE")])).is_none());
199        assert!(
200            Credentials::from_source(env(&[(OAUTH_CLIENT_SECRET_ENV, "tskey-client-example")]))
201                .is_none()
202        );
203    }
204
205    #[test]
206    fn an_empty_or_blank_value_is_not_a_credential() {
207        assert!(Credentials::from_source(env(&[(API_KEY_ENV, "")])).is_none());
208        assert!(Credentials::from_source(env(&[(API_KEY_ENV, "   ")])).is_none());
209    }
210
211    #[test]
212    fn scopes_accept_either_separator() {
213        for raw in [
214            "devices:read,dns:read",
215            "devices:read dns:read",
216            " devices:read , dns:read ",
217        ] {
218            let creds = Credentials::from_source(env(&[
219                (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
220                (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example"),
221                (OAUTH_SCOPES_ENV, raw),
222            ]))
223            .expect("a credential");
224            match creds {
225                Credentials::OauthClient { scopes, .. } => {
226                    assert_eq!(scopes, ["devices:read", "dns:read"], "from {raw:?}");
227                }
228                other => panic!("expected an OAuth client, got {other:?}"),
229            }
230        }
231    }
232
233    #[test]
234    fn the_tailnet_defaults_to_the_one_the_credential_belongs_to() {
235        assert_eq!(tailnet_from_source(env(&[])), DEFAULT_TAILNET);
236        assert_eq!(
237            tailnet_from_source(env(&[(TAILNET_ENV, "  ")])),
238            DEFAULT_TAILNET
239        );
240        assert_eq!(
241            tailnet_from_source(env(&[(TAILNET_ENV, "example.com")])),
242            "example.com"
243        );
244    }
245
246    #[test]
247    fn a_credential_never_prints_its_value() {
248        let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-secretpart")]))
249            .expect("a credential");
250        assert!(!format!("{creds:?}").contains("secretpart"), "{creds:?}");
251    }
252}