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 no_credential_is_a_valid_answer() {
149        assert!(Credentials::from_source(env(&[])).is_none());
150    }
151
152    #[test]
153    fn an_api_key_is_read() {
154        let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-def")]))
155            .expect("a key is a credential");
156        match creds {
157            Credentials::ApiKey(key) => assert_eq!(key.expose(), "tskey-api-example-def"),
158            other => panic!("expected an API access token, got {other:?}"),
159        }
160    }
161
162    #[test]
163    fn an_api_key_wins_over_an_oauth_client() {
164        let creds = Credentials::from_source(env(&[
165            (API_KEY_ENV, "tskey-api-example-def"),
166            (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
167            (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
168        ]))
169        .expect("a credential");
170        assert_eq!(creds.kind(), "API access token");
171    }
172
173    #[test]
174    fn an_oauth_client_wins_over_a_federated_identity() {
175        let creds = Credentials::from_source(env(&[
176            (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
177            (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example-def"),
178            (OAUTH_JWT_FILE_ENV, "/run/secrets/token"),
179        ]))
180        .expect("a credential");
181        assert_eq!(creds.kind(), "OAuth client");
182    }
183
184    #[test]
185    fn a_jwt_file_alone_is_a_federated_identity() {
186        let creds = Credentials::from_source(env(&[(OAUTH_JWT_FILE_ENV, "/run/secrets/token")]))
187            .expect("a credential");
188        match creds {
189            Credentials::Federated {
190                client_id,
191                jwt_file,
192                ..
193            } => {
194                assert_eq!(client_id, None);
195                assert_eq!(jwt_file, PathBuf::from("/run/secrets/token"));
196            }
197            other => panic!("expected a federated identity, got {other:?}"),
198        }
199    }
200
201    #[test]
202    fn half_an_oauth_client_is_not_a_credential() {
203        assert!(Credentials::from_source(env(&[(OAUTH_CLIENT_ID_ENV, "kExAmPlE")])).is_none());
204        assert!(
205            Credentials::from_source(env(&[(OAUTH_CLIENT_SECRET_ENV, "tskey-client-example")]))
206                .is_none()
207        );
208    }
209
210    #[test]
211    fn an_empty_or_blank_value_is_not_a_credential() {
212        assert!(Credentials::from_source(env(&[(API_KEY_ENV, "")])).is_none());
213        assert!(Credentials::from_source(env(&[(API_KEY_ENV, "   ")])).is_none());
214    }
215
216    #[test]
217    fn scopes_accept_either_separator() {
218        for raw in [
219            "devices:read,dns:read",
220            "devices:read dns:read",
221            " devices:read , dns:read ",
222        ] {
223            let creds = Credentials::from_source(env(&[
224                (OAUTH_CLIENT_ID_ENV, "kExAmPlE"),
225                (OAUTH_CLIENT_SECRET_ENV, "tskey-client-example"),
226                (OAUTH_SCOPES_ENV, raw),
227            ]))
228            .expect("a credential");
229            match creds {
230                Credentials::OauthClient { scopes, .. } => {
231                    assert_eq!(scopes, ["devices:read", "dns:read"], "from {raw:?}");
232                }
233                other => panic!("expected an OAuth client, got {other:?}"),
234            }
235        }
236    }
237
238    #[test]
239    fn the_tailnet_defaults_to_the_one_the_credential_belongs_to() {
240        assert_eq!(tailnet_from_source(env(&[])), DEFAULT_TAILNET);
241        assert_eq!(
242            tailnet_from_source(env(&[(TAILNET_ENV, "  ")])),
243            DEFAULT_TAILNET
244        );
245        assert_eq!(
246            tailnet_from_source(env(&[(TAILNET_ENV, "example.com")])),
247            "example.com"
248        );
249    }
250
251    #[test]
252    fn a_credential_never_prints_its_value() {
253        let creds = Credentials::from_source(env(&[(API_KEY_ENV, "tskey-api-example-secretpart")]))
254            .expect("a credential");
255        assert!(!format!("{creds:?}").contains("secretpart"), "{creds:?}");
256    }
257}